279 Commits

Author SHA1 Message Date
157f1dfb18 docs(research): add alknet-docker POC summary — validates two-carriage model (JSON + raw) for bollard docker ops over framed bidi streams 2026-07-02 17:08:52 +00:00
e258ce0523 docs(review): mark review-streaming-impl completed — ADR-049 streaming handler review passes all 12 checklist points 2026-07-02 10:12:19 +00:00
ab610730c0 docs(http): mark http/server/subscribe-sse-streaming completed — /subscribe pipes BoxStream to SSE 2026-07-02 10:10:55 +00:00
c77024cdf5 fix(http): update websocket subscription tests to expect call.responded (dispatch_requested now routes Subscription via invoke_streaming) 2026-07-02 10:10:42 +00:00
9e4d17b1c5 feat(http/server/subscribe-sse-streaming): wire /subscribe to invoke_streaming and pipe BoxStream to SSE
Replace the one-event placeholder (subscribe_stream_from_envelope +
envelope_to_sse_stream, which called invoke() and wrapped the single
ResponseEnvelope) with the real streaming path: subscribe_handler now
calls GatewayDispatch::invoke_streaming() and pipes the
BoxStream<ResponseEnvelope> to SSE via subscribe_stream_from_envelope_stream
(futures::StreamExt::map). Each Ok(output) becomes a data: frame; each
Err becomes an event:error frame (terminal — stream ends after it);
natural stream end closes the SSE. Internal ops still return a single
NOT_FOUND error event via subscribe_stream_internal_error (kept). Client
disconnect drops the stream via Rust's Drop (abort cascade per ADR-016).
2026-07-02 10:04:27 +00:00
c34b4d2df4 docs(call): mark call/protocol/dispatch-streaming-branch completed — server-side streaming dispatch 2026-07-02 09:59:00 +00:00
2905e55e72 Merge branch 'feat/call/protocol/dispatch-streaming-branch' into develop 2026-07-02 09:58:37 +00:00
c58eccd5a6 feat(call/protocol/dispatch-streaming-branch): branch handle_stream on op_type; Subscription → invoke_streaming → pump each → call.completed
Add DispatchResult::Once|Stream enum and Dispatcher::dispatch() that branches
on the registered op_type (ADR-049 §6): Query/Mutation → invoke() (unchanged
Once path), Subscription → invoke_streaming() (Stream path). handle_stream
matches on DispatchResult: the Once path writes one call.responded/call.error
frame (no call.completed); the Stream path pumps each ResponseEnvelope to the
wire via pump_stream (ResponseEnvelope.into() → call.responded for Ok,
call.error for Err), then writes call.completed on natural stream end. An Err
envelope is terminal — last_was_error suppresses call.completed after an error.
The streaming branch clears context.deadline to None (subscriptions are
unbounded — ADR-049 §6, call-protocol Timeouts). Abort (ADR-016) needs no new
code: handle_abort removes the pending entry and dropping the pump task
releases handler resources via Drop. dispatch_requested delegates to dispatch
for backward compatibility with existing callers.
2026-07-02 09:56:05 +00:00
b673b7f317 docs(http): mark http/gateway/invoke-streaming completed — GatewayDispatch::invoke_streaming() 2026-07-02 09:55:15 +00:00
4ac8d308e6 feat(http/gateway/invoke-streaming): add GatewayDispatch::invoke_streaming
Add the streaming analogue of invoke() returning BoxStream<ResponseEnvelope>.
Security invariants are identical to invoke() (internal: false,
forwarded_for: None, same capabilities/scoped_env/ACL) — shared via a
build_root_context_inner helper with a bounded flag. The streaming path
sets deadline: None (unbounded subscriptions, ADR-049 §6). Calls
OperationRegistry::invoke_streaming() (already on develop). to_mcp is
unchanged (MCP excludes Subscription, ADR-041).

Tests cover: subscription dispatch, leading-slash strip, unknown op
NOT_FOUND, internal op NOT_FOUND (not leaked), None identity FORBIDDEN,
Query op INVALID_OPERATION_TYPE, invoke() on Subscription returns
INVALID_OPERATION_TYPE (guard holds through gateway), and
build_root_context_streaming sets deadline: None while carrying the
registration bundle.
2026-07-02 09:54:14 +00:00
62bebe5122 docs(http): mark http/adapters/from-openapi-sse-streaming completed — SSE streaming forwarding 2026-07-02 09:48:39 +00:00
a1e4752fdf Merge branch 'feat/http/adapters/from-openapi-sse-streaming' into develop 2026-07-02 09:48:07 +00:00
6f05dd8995 feat(http/adapters/from-openapi-sse-streaming): branch from_openapi forwarding on op_type; Subscription → StreamingHandler (SSE → BoxStream<ResponseEnvelope>)
build_registration now branches on op_type: Subscription ops register a
StreamingHandler (HandlerKind::Stream) via make_streaming_handler that
streams SSE response chunks as ResponseEnvelope::ok() items (one per
data: frame); Query/Mutation ops keep the existing Handler
(HandlerKind::Once) via forward(). Closes the gap where a from_openapi-
imported Subscription returned only the last SSE event.

- forward_stream(): non-async fn returning ResponseStream; sends the
  request with Accept: text/event-stream, then streams SSE chunks via
  stream::unfold over response.bytes_stream(), reusing parse_sse_frames
  (multi-event, partial trailing, comments, multi-line data, BOM).
  HTTP error (non-2xx) → single ResponseEnvelope::error(), stream ends;
  SSE stream end → ResponseStream ends (→ call.completed on wire).
- Removed stream_subscription() (the collect-all placeholder that
  truncated to the last event). parse_sse_frames stays (reused).
- Query/Mutation forwarding unchanged (existing forward() path).
- Tests: Subscription registration is HandlerKind::Stream; Query
  registration is HandlerKind::Once; SSE subscription streams multiple
  ResponseEnvelope::ok() (one per data: frame); HTTP error → single
  error envelope; Query forwarding unchanged (single response).
2026-07-02 09:45:55 +00:00
d841cc35b9 docs(call): mark call/client/from-call-streaming-forwarding completed — streaming forwarding handler 2026-07-02 09:45:48 +00:00
5c37e5b3af Merge branch 'feat/call/client/from-call-streaming-forwarding' into develop 2026-07-02 09:45:29 +00:00
67b1adba98 feat(call/client/from-call-streaming-forwarding): branch from_call forwarding on op_type
Subscription ops discovered via services/list + services/schema now
register a StreamingHandler (HandlerKind::Stream) that calls
CallConnection::subscribe_with_payload and forwards the remote stream
end-to-end (ADR-049 §8). Query/Mutation ops keep the existing
make_forwarding_handler (HandlerKind::Once).

- Add CallConnection::subscribe_with_payload(payload) mirroring
  call_with_payload so the forwarding handler can populate forwarded_for
  (ADR-032) + auth_token on the subscription payload. subscribe() now
  delegates to subscribe_with_payload.
- Add make_streaming_forwarding_handler() in from_call.rs using
  make_streaming_handler + futures::stream::once(...).flatten() to await
  subscribe_with_payload then forward its stream.
- Branch build_bundles on spec.op_type (already parsed by rebuild_spec_for).
- Reuse build_forwarded_payload — no new payload-construction code.
- composition_authority: None, scoped_env: None for FromCall streaming
  leaves (same as Query/Mutation FromCall leaves).
- Abort cascade (ADR-016 §6) already wired via PendingRequestMap in
  subscribe_with_payload.

Closes the gap where a from_call-imported Subscription truncated to the
first value.
2026-07-02 09:43:45 +00:00
f12e227df0 docs(call): mark call/registry/invoke-streaming completed — invoke_streaming() streaming dispatch 2026-07-02 09:41:59 +00:00
acaa0513e4 feat(call/registry): add OperationRegistry::invoke_streaming() returning ResponseStream
Streaming dispatch path for Subscription operations — counterpart to
invoke(). Same visibility + ACL checks (internal → handler_identity,
external → identity), then dispatches to the StreamingHandler. Pre-handler
errors (not-found, forbidden, INVALID_OPERATION_TYPE for non-Subscription
ops) yield a single error ResponseEnvelope via stream::once and end the
stream. Adds 6 unit tests covering dispatch, not-found, wrong-kind,
internal-from-external, ACL denied, and internal-call handler_identity ACL.

Refs ADR-049 §3, §5.
2026-07-02 09:39:31 +00:00
185ddb82b5 docs(call): mark call/registry/streaming-handler-handlerkind completed — StreamingHandler/HandlerKind foundation 2026-07-02 09:29:11 +00:00
9c81129f24 feat(call): introduce StreamingHandler, HandlerKind, ResponseStream + INVALID_OPERATION_TYPE (ADR-049)
Add the foundational types for ADR-049 streaming handlers:
- StreamingHandler, ResponseStream type aliases and HandlerKind enum
  (Once | Stream) in registration.rs, with make_streaming_handler() helper
- CallError::invalid_operation_type() in wire.rs (sixth protocol code,
  retryable: false)
- HandlerRegistration.handler flipped from Handler to HandlerKind;
  HandlerRegistration::new() now takes HandlerKind
- OperationRegistryBuilder absorbs wrapping: with_local/with_leaf/
  with_leaf_provenance wrap raw Handler in HandlerKind::Once for
  Query/Mutation; new with_local_streaming/with_leaf_streaming take a
  StreamingHandler and wrap in HandlerKind::Stream for Subscription.
  Builder validates kind matches spec.op_type (mismatch = startup error)
- OperationRegistry::register() returns Result<(), String> with a clear
  mismatch message; all call sites updated to handle the Result
- invoke() matches on HandlerKind: Once -> existing path; Stream ->
  INVALID_OPERATION_TYPE error envelope (guards against silent
  truncation; invoke_streaming() added in a downstream task)
- OverlayOperationEnv::invoke_with_policy matches on HandlerKind:
  Once -> dispatch; Stream -> INVALID_OPERATION_TYPE (composition is
  request/response-only)
- Migrated every HandlerRegistration::new() construction site (~95)
  to wrap raw Handler in HandlerKind::Once(handler); the builder sites
  are handled by the builder-absorbs-wrapping change
- Updated two websocket subscription tests that relied on Subscription
  ops dispatching via invoke() to expect INVALID_OPERATION_TYPE
- Added unit tests for invoke/register validation and
  make_streaming_handler
2026-07-02 09:28:05 +00:00
07f7607fbb tasks(decomp): ADR-049 streaming handler — 8 atomic tasks + gitignore .worktrees/
Decompose the ADR-049 streaming handler work into 8 dependency-ordered tasks:
- call/registry/streaming-handler-handlerkind (foundation: StreamingHandler,
  HandlerKind, ResponseStream, INVALID_OPERATION_TYPE, migrate all sites)
- call/registry/invoke-streaming (OperationRegistry::invoke_streaming)
- call/protocol/dispatch-streaming-branch (server-side op_type branch)
- call/client/from-call-streaming-forwarding (Subscription → subscribe())
- http/gateway/invoke-streaming (GatewayDispatch::invoke_streaming)
- http/server/subscribe-sse-streaming (/subscribe pipes BoxStream to SSE)
- http/adapters/from-openapi-sse-streaming (SSE → StreamingHandler)
- review-streaming-impl (phase review checkpoint)

Validated with taskgraph: 86 tasks, no cycles. Also ignore .worktrees/ so
agents' worktree workspaces don't leak into git status.
2026-07-02 08:23:27 +00:00
7ecc11610a docs(arch): ADR-049 — streaming handler for subscription operations
The call protocol spec describes streaming (call.responded*N +
call.completed, PendingRequestMap::Subscribe, CallConnection::subscribe),
but the server-side Handler type returned a single ResponseEnvelope —
a Subscription op had no way to produce a stream. The TS predecessor
(@alkdev/operations) had separate OperationHandler / SubscriptionHandler
types; the Rust port collapsed them, losing the streaming path. This
restores it end-to-end: StreamingHandler type, HandlerKind on
HandlerRegistration validated against op_type, invoke_streaming() on
OperationRegistry, server-side dispatch branches on op_type, new
INVALID_OPERATION_TYPE protocol code for wrong-dispatch-path misuse,
GatewayDispatch::invoke_streaming() for /subscribe SSE, from_call stream
forwarding via CallConnection::subscribe(), from_openapi SSE forwarding.
OperationEnv::invoke() stays request/response-only (stream composition is
handler-level, not protocol-level). Amends ADR-023's protocol-code list
(five → six). Tracks the stream-operators library as OQ-41 (feature
extension, not an unmade decision).
2026-07-02 07:43:01 +00:00
139c651eaa docs(http): mark http/review-http-final completed — alknet-http crate review complete
Final crate-wide review passes all 9 checklist areas: crate structure, feature gate
isolation, dependencies, cross-cutting concerns, pattern consistency, ADR conformance
(003-048), absence of deferred/out-of-scope items, test coverage (277+2 alknet-call,
230 default, 265+5 mcp), build cleanliness (fmt/clippy/build all clean).
2026-07-01 23:40:29 +00:00
5a51734291 docs(http): mark http/review-websocket and http/review-http completed 2026-07-01 23:39:40 +00:00
b3ab6ef097 docs(http): mark http/adapters/to-openapi completed + fix formatting
to_openapi gateway projection merged: 5-endpoint OpenAPI doc (ADR-042/045), pure
projection, info.version 1.0.0, error fidelity (ADR-023). 230 tests pass. Clippy clean.
2026-07-01 23:37:35 +00:00
ccaac7e157 Merge feat/http-to-openapi: to_openapi gateway projection (5-endpoint OpenAPI doc, ADR-042/045)
Implements to_openapi(registry) -> OpenAPISpec in src/adapters/to_openapi.rs — pure
projection generating fixed 5-endpoint gateway doc (/search, /schema, /call, /batch,
/subscribe) with info.version = 1.0.0 (ADR-045). /call responses carry protocol-level
errors + operation-level errors mapped by http_status (ADR-023). Per-caller operation
surface NOT preloaded (discovered via /search, ADR-042). /subscribe response is
text/event-stream. Wired GET /openapi.json in adapter.rs. 16 new tests.
2026-07-01 23:36:48 +00:00
18156ac9d2 Merge origin/develop: integrate connection-overlay (resolve adapter.rs test conflict, keep /openapi.json route test) 2026-07-01 20:17:06 +00:00
dd6aacc598 feat(http): complete to_openapi gateway projection with error fidelity and route wiring
Refine to_openapi to project operation-level errors (with http_status)
onto /call and /subscribe responses via oneOf merge with protocol-level
errors, preserving HTTP_<status> prefix codes without collision. Fix
BTreeMap→serde_json::Map for Value::Object compatibility. Wire GET
/openapi.json route test. Apply cargo fmt across the crate.
2026-07-01 20:11:09 +00:00
2695a19502 feat(http): implement to_openapi gateway projection (5-endpoint OpenAPI doc, info.version 1.0.0)
to_openapi(registry) -> OpenAPISpec generates the fixed 5-endpoint
gateway doc (/search, /schema, /call, /batch, /subscribe) — pure
projection (ADR-017 §5), gateway pattern (ADR-042). info.version is
1.0.0 tracking the gateway contract (ADR-045). /call responses carry
protocol-level errors (400/401/403/404/500/504) plus operation-level
errors mapped by http_status (ADR-023). GET /openapi.json wired to
serve the generated spec.
2026-07-01 19:52:57 +00:00
0b0bceec7a docs(http): mark http/websocket/connection-overlay completed 2026-07-01 19:48:36 +00:00
468bd84f86 Merge feat/http-connection-overlay: Connection-local Layer 2 overlay for browser-registered ops
Adds AccessControl::check to OverlayOperationEnv::invoke_with_policy in alknet-call
so hub's calls to browser-registered ops are gated by the browser's AccessControl.
Creates src/websocket/overlay.rs with 19 integration tests: overlay scoping (not
PeerCompositeEnv), no PeerId, register_imported/all, overlay_env() routing,
PeerRef::Specific→NOT_FOUND, AccessControl gating, overlay drop on WS close,
ADR-016 abort cascade, bidirectionality, no-ops use-case scoping.
2026-07-01 19:46:56 +00:00
ad279693ce feat(http): connection-local Layer 2 overlay for browser-registered ops (ADR-024/034/044)
Enforce AccessControl on overlay ops in OverlayOperationEnv::invoke_with_policy
(alknet-call) so the hub's calls to browser-registered ops are gated by the
browser's AccessControl — matching OperationRegistry::invoke semantics for
internal composition (caller identity = parent handler_identity.as_identity()).

Add src/websocket/overlay.rs with 19 integration tests covering the connection-
local overlay acceptance criteria: browser ops land in the per-CallConnection
overlay (not PeerCompositeEnv), no PeerId for the browser, register_imported()/
register_imported_all() populate the overlay, hub outgoing calls route through
overlay_env() (not PeerRef::Specific), PeerRef::Specific('browser-X') routes to
NOT_FOUND, AccessControl gates hub calls (allowed/forbidden/default), overlay is
per-connection isolated and dropped on WS close, WS close aborts in-flight calls
with ADR-016 cascade, bidirectionality, and browser-with-no-ops use-case scoping.
2026-07-01 19:45:36 +00:00
58e16d088b review(http): mark http/review-mcp completed + fix formatting across crate
Review-mcp verification complete: all 12 checklist items pass (from_mcp/to_mcp
conformance, ADR-037/041/014/023/034, feature gate isolation, GatewayDispatch
concrete struct, test coverage 223+5). Applied cargo fmt across crate.
2026-07-01 19:32:42 +00:00
48ead6950b docs(http): mark http/review-mcp completed 2026-07-01 19:31:49 +00:00
643f8727d2 review(http): fix formatting in from_mcp/mod.rs (review-mcp finding) 2026-07-01 19:31:27 +00:00
86752ba242 docs(http): mark http/adapters/to-mcp completed 2026-07-01 19:24:50 +00:00
b127699aad Merge feat/http-to-mcp: to_mcp gateway projection (4-tool gateway, rmcp StreamableHttpService)
Implements src/adapters/to_mcp.rs: ToMcpGateway rmcp ServerHandler with 4 fixed
gateway tools (search/schema/call/batch). search dispatches services/list (ACL-
filtered, excludes Subscriptions), schema dispatches services/schema, call/batch
dispatch via GatewayDispatch::invoke with ResponseEnvelope→CallToolResult mapping.
Bearer auth via shared middleware around nest_service. Identity survives rmcp
framing (research §6 #2 confirmed). Feature-gated behind mcp; stdio NOT built
(ADR-037). Pure projection. 16 unit tests.

# Conflicts:
#	crates/alknet-http/src/server/adapter.rs
2026-07-01 19:24:33 +00:00
974fcde923 docs(http): mark http/server/gateway-endpoints completed 2026-07-01 19:20:21 +00:00
31291bd2d9 Merge feat/http-gateway-endpoints: 5 fixed gateway endpoints (search/schema/call/batch/subscribe)
Implements src/server/gateway_routes.rs: POST /call, GET /search, GET /schema,
POST /batch, POST /subscribe (SSE). All delegate to GatewayDispatch::invoke; auth
via ResolvedIdentity extractor; errors mapped via call_error_to_http_response
(identity-aware 401/403 split). Internal ops → 404. /schema adds ACL pre-check.
/subscribe projects ResponseEnvelope as SSE. /batch loops over invoke. Wired real
handlers into adapter.rs replacing placeholder 501s. 157 tests pass.

Note: /subscribe SSE completes after single event (registry invoke returns single
ResponseEnvelope, no streaming handler yet — research §6 OQ#5).

# Conflicts:
#	crates/alknet-http/src/server/adapter.rs
2026-07-01 19:19:50 +00:00
64696fec97 feat(http): implement to_mcp 4-tool gateway projection (rmcp ServerHandler, StreamableHttpService at /mcp)
to_mcp is the MCP-direction gateway projection (ADR-041): exposes 4 fixed
gateway tools (search, schema, call, batch) over rmcp StreamableHttpService
nested into the axum Router at /mcp, not one MCP tool per registry operation.
The LLM discovers operations on demand via search+schema.

- ToMcpGateway implements rmcp ServerHandler (call_tool, list_tools, get_info)
- tools/list returns the 4 fixed gateway tools, never the registry's ops
- search dispatches services/list via GatewayDispatch::invoke, excludes
  Subscription ops (ADR-041 §2), returns names + descriptions
- schema dispatches services/schema, returns the full OperationSpec
- call dispatches via GatewayDispatch::invoke (shared spine), maps
  ResponseEnvelope -> CallToolResult::structured (Ok) /
  CallToolResult::structured_error (Err(CallError))
- batch loops over invoke, returns an array of results
- Bearer auth via shared bearer_auth_middleware applied around nest_service
  (rmcp simple_auth_streamhttp pattern); Identity read from
  RequestContext.extensions -> http::request::Parts.extensions
  (research §6 #2 identity-survives-framing assumption, confirmed via test)
- to_mcp is a pure projection (consumes registry, produces no entries)
- Feature-gated behind mcp; stdio NOT built (ADR-037)
- /mcp route wired in adapter.rs replacing the placeholder 501

cargo test -p alknet-http --features mcp: 172 passed
cargo clippy -p alknet-http --features mcp --all-targets: clean
cargo check -p alknet-http (no mcp): clean
2026-07-01 19:18:19 +00:00
5a629a48e5 feat(http): implement 5 gateway endpoints (search/schema/call/batch/subscribe)
Implement the sole HTTP invoke path (ADR-042/047) in
src/server/gateway_routes.rs: POST /call reads { operation, input },
resolves identity via the shared ResolvedIdentity extractor, dispatches
via GatewayDispatch::invoke, and returns ResponseEnvelope as JSON with
errors mapped via call_error_to_http_response. GET /search dispatches
services/list (AccessControl-filtered); GET /schema dispatches
services/schema with an ACL pre-check (unauthorized -> 403). POST /batch
loops over invoke returning an array; POST /subscribe projects the
response as SSE (text/event-stream) with data frames for call.responded
and an error event for call.aborted. Internal ops return 404. Wire the
real handlers into adapter.rs router, replacing the placeholder 501s.
2026-07-01 19:17:59 +00:00
f0710f8a04 docs(http): mark http/websocket/upgrade-handler completed 2026-07-01 19:16:37 +00:00
4306a046fe Merge feat/http-upgrade-handler: WebSocket upgrade handler (native EventEnvelope session, no length prefix)
Implements src/websocket/upgrade.rs: WS upgrade at /alknet/call using axum
WebSocketUpgrade, bearer auth (no token → 401), CallConnection::new_overlay_only,
native EventEnvelope over binary WS messages (no length prefix, text → close 1002),
Dispatcher::dispatch_requested for call.requested (AccessControl gates), handle_abort
for call.aborted, PendingRequestMap correlation, fail_all_pending on disconnect
(ADR-016 cascade), bidirectionality via connection-local overlay. Wired /alknet/call
route into adapter.rs. 168 tests pass.
2026-07-01 19:15:46 +00:00
384ad03619 feat(http): implement WebSocket upgrade handler (native EventEnvelope session, no length prefix, bearer auth) 2026-07-01 19:15:11 +00:00
539a812c12 docs(http): mark http/server/bearer-auth-middleware completed 2026-07-01 18:52:06 +00:00
ccbff3c7f8 Merge feat/http-bearer-auth-middleware: Shared Bearer auth middleware
Implements src/server/auth.rs: bearer_auth_middleware (from_fn_with_state over
Arc<dyn IdentityProvider>, stashes Option<Identity> in request extensions),
extract_bearer_identity (Bearer-only: no/malformed/Basic/unresolvable → None,
not an error), ResolvedIdentity axum extractor. Wired into HttpAdapter router
via route_layer around gateway/openapi/mcp routes, excluding /healthz. 11 tests.

# Conflicts:
#	crates/alknet-http/Cargo.toml
#	crates/alknet-http/src/server/adapter.rs
#	crates/alknet-http/src/server/mod.rs
2026-07-01 18:51:29 +00:00
36f74dd31b feat(http): implement shared Bearer auth middleware (resolve_from_token, stash Identity in request extensions)
Add src/server/auth.rs with bearer_auth_middleware axum layer that
extracts the Authorization: Bearer header, resolves via
IdentityProvider::resolve_from_token, and stashes Option<Identity> in
request extensions. Shared by HTTP gateway routes and the to_mcp rmcp
service (research §4.4). No token, malformed header, or failed
resolution all yield None (unauthenticated, not an error) — Bearer-only
auth mechanism (ADR-004).

Includes ResolvedIdentity axum extractor reading from extensions, and
wires the middleware into the HttpAdapter router around the
gateway/openapi/mcp routes (excluding the raw /healthz route).
2026-07-01 18:48:00 +00:00
35a7a37ba6 docs(http): mark http/server/healthz-decoy completed 2026-07-01 18:41:14 +00:00
78344d9280 Merge feat/http-healthz-decoy: /healthz raw route + stealth decoy fallback
Implements GET /healthz (src/server/healthz.rs, 200 OK text/plain 'ok', no auth,
no OperationContext) and stealth decoy fallback (src/server/decoy.rs: DecoyConfig
NotFound=nginx 404 / StaticSite=serve files / Redirect). Wired real handlers into
HttpAdapter router replacing placeholder 501s. 125 tests pass.
2026-07-01 18:40:35 +00:00
3702da1aee feat(http): implement /healthz raw route and stealth decoy fallback
GET /healthz: raw route, no auth, no OperationContext, returns 200 OK
with plain-text 'ok' (ADR-036). Decoy fallback for unknown paths via
DecoyConfig: fake nginx 404 (default), static site serving, or redirect.
Decoy does not leak alknet presence (no alknet headers/format). Custom
routes take precedence over decoy (decoy is fallback only). Wire real
handlers into HttpAdapter router replacing placeholder 501s.
2026-07-01 18:40:01 +00:00
a65afb0dfb docs(http): mark http/adapters/from-mcp completed 2026-07-01 18:24:14 +00:00
3eb2a51184 Merge feat/http-from-mcp: from_mcp adapter (rmcp streamable HTTP, tools/list, structuredContent handling)
Implements FromMCP (feature-gated behind mcp) in src/adapters/from_mcp/: rmcp
StreamableHttpClientTransport connects to MCP endpoint, calls tools/list, builds
HandlerRegistration bundles (provenance FromMCP, leaf, Internal, Mutation,
capabilities=bearer token). Forwarding handler calls client.call_tool, maps
CallToolResult per structuredContent-preferred-over-content-blocks rule (declared
outputSchema → structuredContent; absent → ContentBlock union; no heuristic
JSON.parse; isError→CallError). No-env-vars (reads context.capabilities).
Streamable HTTP only (ADR-037). 19 unit + 5 integration tests.

# Conflicts:
#	crates/alknet-http/src/adapters/mod.rs
2026-07-01 18:22:32 +00:00
c9e5ea1c75 feat(http): implement from_mcp adapter (rmcp streamable HTTP client, tools/list discovery, structuredContent handling)
FromMCP (OperationAdapter, feature-gated behind mcp) discovers remote MCP
tools over streamable HTTP via rmcp's StreamableHttpClientTransport, calls
tools/list, and registers each as a HandlerRegistration bundle with a
forwarding handler that calls the remote tool via tools/call. Output
handling follows the structuredContent-preferred-over-content-blocks rule:
declared outputSchema + structuredContent is the composable result; absent
outputSchema falls back to the MCP ContentBlock union. isError:true maps to
a CallError with the error content. No-env-vars invariant: the handler reads
context.capabilities (injected at registration), never std::env::var (ADR-014).
Streamable HTTP only — stdio is not built (ADR-037). Provenance is FromMCP
(leaf: composition_authority None, scoped_env None, Internal by default,
ADR-015/022). Includes unit tests for schema/mapping logic and an integration
test that spins up a real rmcp streamable HTTP server and exercises the
forwarding handler end-to-end.
2026-07-01 18:21:45 +00:00
4905c06f4b docs(http): mark http/adapters/from-openapi completed 2026-07-01 18:20:23 +00:00
ad8d7879ae Merge feat/http-from-openapi: from_openapi adapter (OpenAPI parser, reqwest forwarding, no-env-vars)
Implements FromOpenAPI in src/adapters/from_openapi.rs: OpenAPISpec/HttpServiceConfig/
HttpAuthScheme types, $ref resolution, OperationAdapter::import() producing
HandlerRegistration bundles (Internal visibility, FromOpenAPI provenance,
HTTP_<status> error codes per ADR-023). Reqwest forwarding handlers read credentials
from OperationContext.capabilities (no-env-vars ADR-014) via SharedHttpClient.
JSON/text/binary response branching, SSE subscription streaming, Bearer/ApiKey/Basic
auth injection. 98 tests pass.
2026-07-01 18:19:44 +00:00
6b30e2ac15 feat(http): implement from_openapi adapter (OpenAPI parse + reqwest forwarding handlers)
Parses OpenAPI 3.x documents into HandlerRegistration bundles with
reqwest-backed forwarding handlers that inject credentials from
OperationContext.capabilities (no-env-vars invariant, ADR-014).
Error codes are prefixed HTTP_<status> (ADR-023); ops are Internal
leaves with FromOpenAPI provenance (ADR-015/022). SSE subscriptions
are consumed via parseSSEFrames; JSON/text/binary response branching
mirrors the TS prior art.
2026-07-01 18:18:28 +00:00
6b96852e4e docs(http): mark http/server/http-adapter completed 2026-07-01 18:09:57 +00:00
1fea747305 Merge feat/http-http-adapter: HttpAdapter (ProtocolHandler for h2/http1.1) — axum over QUIC
Implements HttpAdapter in src/server/adapter.rs: axum-over-QUIC bridge via
hyper-util auto Builder, DecoyConfig (NotFound/StaticSite/Redirect),
with_extra_routes merge (ADR-046), router state holds Arc<OperationRegistry> +
Arc<dyn IdentityProvider>, placeholder 501 handlers for gateway endpoints/
healthz/openapi.json/MCP. h3 ALPN not registered (ADR-044). 78 tests pass.
2026-07-01 18:08:50 +00:00
b313dcbf20 feat(http): implement HttpAdapter (ProtocolHandler for h2/http1.1, axum over QUIC)
Wires the axum Router (gateway endpoints + /healthz + /openapi.json + MCP +
custom routes via extra_routes merge ADR-046) and drives hyper's HTTP/1.1 or
HTTP/2 connection driver over a single QUIC bidirectional stream. The
QUIC-to-hyper bridge wraps the (SendStream, RecvStream) pair as a
TokioIo-compatible duplex and feeds it to hyper-util's auto Builder (which
auto-detects HTTP/1.1 vs HTTP/2). h3 ALPN is not registered (ADR-044).

Route handlers, healthz/decoy logic, openapi.json, the MCP route, and the WS
upgrade handler are wired as 501 Not Implemented placeholders for their
respective tasks. The router state holds Arc<OperationRegistry> +
Arc<dyn IdentityProvider>; the router is built once at construction and
cloned per connection (cheap Arc clone). DecoyConfig defaults to NotFound.

Adds hyper-util dependency (server, service, tokio features).
2026-07-01 18:07:56 +00:00
9df9900bb9 docs(http): mark http/client/shared-http-client completed 2026-07-01 17:23:42 +00:00
ea38f81c12 Merge feat/http-shared-http-client: Shared HTTP client with retry + Retry-After middleware
Implements SharedHttpClient (ArcSwap<ClientWithMiddleware>) with HttpClientConfig
(pool/timeout/retry/optional CA bundle+client cert), RetryTransientMiddleware from
reqwest-retry, and inlined RetryAfterMiddleware (~90 lines, bounded HashMap with LRU
eviction, parses Retry-After seconds + HTTP-date, sleeps on 429/503). reload() via
ArcSwap. No env-var reads; per-request credential injection only. 24 unit tests.
2026-07-01 17:21:55 +00:00
081fc911ef feat(http): implement shared HTTP client (ClientWithMiddleware + retry + Retry-After, OQ-40)
Adds SharedHttpClient wrapping ArcSwap<ClientWithMiddleware> with a
RetryTransientMiddleware + inlined RetryAfterMiddleware stack.
HttpClientConfig covers pool, timeout, retry policy, and optional CA
bundle/client cert. reload() rebuilds and swaps via ArcSwap. No env-var
reads; credential injection is per-request, not at construction.
2026-07-01 17:20:49 +00:00
0da76d4dd5 docs(http): mark http/websocket/dispatcher-transport-abstraction completed 2026-07-01 17:19:44 +00:00
9512e61e73 Merge feat/http-dispatcher-transport-abstraction: Expose EventEnvelope-level dispatch API for non-QUIC transports
Cross-crate change (alknet-call): expose Dispatcher::dispatch_requested as pub,
extract abort-cascade handling into pub handle_abort method, add
CallConnection::new_overlay_only(identity) constructor (Option A) for non-QUIC
transports. Existing QUIC path (CallAdapter, CallClient, run_loop, handle_stream)
unchanged. 13 unit tests in alknet-call + 6 integration tests in alknet-http.
2026-07-01 17:17:54 +00:00
ef53a03589 feat(call,http): expose EventEnvelope-level dispatch API for non-QUIC transports
Make Dispatcher::dispatch_requested pub and extract abort-cascade handling
into a pub handle_abort method so the WebSocket handler can feed deserialized
EventEnvelopes directly to the shared Dispatcher without a QUIC Connection.

CallConnection gains a new_overlay_only(identity) constructor (Option A) that
holds the Layer 2 overlay, PendingRequestMap, and resolved bearer Identity
without a QUIC Connection; identity() reads the stored field for the non-QUIC
case. compose_root_env uses the new identity() accessor for both paths.

The existing QUIC path (CallAdapter, CallClient, run_loop, handle_stream) is
unchanged — outgoing client methods guard on connection().is_none().
2026-07-01 17:17:02 +00:00
8b8b8e8234 docs(http): mark http/gateway/error-mapping completed 2026-07-01 17:10:44 +00:00
81781d89fa Merge feat/http-error-mapping: CallError-to-HTTP status error mapping (ADR-023)
Implements call_error_to_http_status, call_error_to_http_status_with_identity,
and call_error_to_http_response in src/gateway/error.rs. Five protocol codes
map to fixed statuses (404/422/504/500 + 401/403 split for FORBIDDEN).
HTTP_<status>-prefixed operation-level codes parse status from prefix. Unknown
operation-level codes default to 500. Retry-After header for retryable 503/429.
21 unit tests.

# Conflicts:
#	crates/alknet-http/src/gateway/mod.rs
2026-07-01 17:09:25 +00:00
9c959cd863 docs(http): mark http/gateway/gateway-dispatch-spine completed 2026-07-01 17:07:53 +00:00
33fecd5470 feat(http): implement CallError-to-HTTP error mapping (ADR-023)
Add gateway/error.rs with call_error_to_http_status,
call_error_to_http_status_with_identity, and call_error_to_http_response.
Maps the five protocol codes (NOT_FOUND/FORBIDDEN/INVALID_INPUT/TIMEOUT/
INTERNAL) to fixed HTTP statuses, splits FORBIDDEN into 401 (no identity) /
403 (identity present), maps HTTP_<status>-prefixed operation-level codes
to the status number (from_openapi fidelity), and defaults unknown
operation-level codes to 500. Retryable 503/429 errors carry a Retry-After
header when details.retry_after is present.
2026-07-01 17:06:49 +00:00
117085de4e Merge feat/http-gateway-dispatch-spine: GatewayDispatch shared dispatch spine
Concrete struct (not a trait) holding Arc<OperationRegistry> + Arc<dyn IdentityProvider>
with resolve_bearer() and invoke() returning ResponseEnvelope. Builds root
OperationContext for wire-ingress (internal:false, forwarded_for:None, fresh
request_id, 30s deadline). 14 unit tests covering dispatch, AccessControl
filtering, NOT_FOUND for unregistered/Internal ops, FORBIDDEN for unauthorized.
2026-07-01 17:06:22 +00:00
a4ce2c8173 feat(http): implement GatewayDispatch shared dispatch spine
Thin concrete struct (not a trait) holding Arc<OperationRegistry> +
Arc<dyn IdentityProvider>. Exposes resolve_bearer() (delegates to
identity_provider.resolve_from_token) and invoke() which builds a root
OperationContext for wire-ingress (internal: false, forwarded_for: None,
fresh UUID v4 request_id, deadline now+30s) carrying the registration
bundle's composition_authority/capabilities/scoped_env, then calls
OperationRegistry::invoke. Dispatches services/list and services/schema
unchanged (registered ops); AccessControl filtering in services/list
sees the caller's resolved identity. Re-exported from lib.rs.

Duplicates Dispatcher::build_root_context construction (the alknet-call
version is pub(crate) and tangled with CallConnection peer/session
overlays); the invariants (internal: false, forwarded_for: None) are
the load-bearing part and identical to the wire-ingress path.
2026-07-01 17:05:10 +00:00
1900c72deb docs(http): mark http/crate-init completed 2026-07-01 16:44:57 +00:00
87bfaf159a Merge feat/http/crate-init: Initialize alknet-http crate (ADR-039)
Adds crates/alknet-http with Cargo.toml, src/lib.rs, and five subsystem
module skeletons (server, gateway, client, adapters, websocket). Workspace
members list updated. Dependencies: alknet-core, alknet-call (workspace path),
axum, hyper, reqwest stack, openapiv3; rmcp optional behind mcp feature
(streamable HTTP only, ADR-037). h3/WebTransport absent (ADR-044).
2026-07-01 16:42:38 +00:00
c2e6ba5b96 feat(http): initialize alknet-http crate with module skeleton
Add crates/alknet-http with Cargo.toml, src/lib.rs, and the five
subsystem modules (server, gateway, client, adapters, websocket) per
ADR-039 (server + client host colocated). The mcp feature gate pulls in
rmcp with streamable HTTP transport features only (ADR-037 — no stdio);
h3/WebTransport is absent (deferred per ADR-044). alknet-core and
alknet-call use workspace path deps. The crate is added to the workspace
members list.
2026-07-01 16:41:14 +00:00
e855c8c7eb docs(http): decompose alknet-http spec into 19 implementation tasks
Break the alknet-http architecture spec into atomic, dependency-ordered
tasks in tasks/http/, following the taskgraph frontmatter conventions
used by the call/core/vault crates.

Tasks span 7 phases across 5 module subdirectories (server/, gateway/,
client/, adapters/, websocket/):
- Phase 0: crate-init (foundation)
- Phase 1: gateway-dispatch-spine, error-mapping, shared-http-client
  (shared infrastructure)
- Phase 2: http-adapter, bearer-auth-middleware, gateway-endpoints,
  healthz-decoy (HTTP server surface)
- Phase 3: to-openapi (OpenAPI gateway projection)
- Phase 4: from-openapi (OpenAPI adapter, reqwest forwarding)
- Phase 5: dispatcher-transport-abstraction, upgrade-handler,
  connection-overlay (WebSocket browser bidirectional path)
- Phase 6: from-mcp, to-mcp (MCP adapters, feature-gated)
- Phase 7: review-http, review-websocket, review-mcp, review-http-final
  (quality checkpoints)

The gateway-dispatch-spine task implements the thin shared core
recommended by the gateway-factoring research (concrete struct, not a
trait). The dispatcher-transport-abstraction task is a cross-crate
change to alknet-call (exposes EventEnvelope-level dispatch API for
non-QUIC transports) — the highest-risk task. WebTransport/h3 is
deferred per ADR-044 and has no tasks; from_wss is out of scope.

Validated: 19 tasks, no cycles, 8 parallel generations, critical path
length 8 (through the WebSocket strand).
2026-07-01 07:11:17 +00:00
e0c6f61e6a docs(http): pre-decomposition sanity check fixes — /subscribe POST, direct-call cleanup, from_mcp output handling
Three issues found in the http crate spec sanity check that would have
caused problems during task decomposition, now fixed:

C1 — /subscribe GET→POST: the gateway's /subscribe is an invoke endpoint
carrying { operation, input } in the body, but was listed as GET (which
has no body). Flipped to POST with Accept: text/event-stream negotiating
the SSE response, consistent with /call's flat-JSON-body invariant.
Browsers using EventSource can't POST but use WebSocket for the
bidirectional path; the HTTP gateway's /subscribe is for non-browser
HTTP clients (fetch + ReadableStream). Touches ADR-042, ADR-047,
ADR-048, http-adapters.md, http-server.md.

C2 — stale direct-call references: three spots contradicted ADR-047
(which removed the POST /{service}/{op} direct-call surface) and
ADR-046 §3 (which states /{service}/{op} is no longer reserved).
Cleaned up in http-server.md (custom-routes intro + collision list) and
ADR-046 §6 (default-surface list).

W2 — from_mcp output handling: the spec's fallback for tools without
outputSchema was Type.Unknown(), but the correct fallback is the MCP
ContentBlock union (text|image|audio|resource|resource_link) — a
well-defined MCP type, not Unknown. Fixed http-mcp.md with the full
structuredContent-preferred-over-content-blocks logic (matching the TS
adapter and rmcp SDK), enriched references with specific rmcp source
files. Also added shared-dispatch-spine notes to http-mcp.md and
http-adapters.md cross-referencing the new research findings.

Research (docs/research/alknet-http-gateway-factoring/findings.md):
to_mcp and to_openapi share a dispatch spine (resolve → invoke → map).
Recommendation: extract a thin shared struct now, not a GatewayDispatch
trait — the server-integration layers (axum routes vs rmcp
StreamableHttpService) and wire-framing stay per-gateway. A third
gateway is not on the horizon; if one appears its server-integration
needs its own shape anyway.

Minor: WS route precedence note (websocket.md), OpenAPISpec
shared-type-not-shape clarification (http-adapters.md), date bumps.
2026-07-01 05:41:07 +00:00
3edc42e3b4 docs(compute): add wonnx + handlebars/wgpu reference implementations
Document the two codebases that inform the ShaderGenerator's op table
and the wgpu+handlebars+remote-GPU patterns:

- wonnx (MIT/Apache-2.0, archived): comprehensive ONNX op set in
  Tera-templated WGSL at wonnx/templates/ — arithmetic, activation,
  gemm, conv, batchnorm, softmax, etc. Port the shader implementations,
  swap Tera for handlebars. compiler.rs's add_raw_template +
  include_str! pattern maps 1:1 to handlebars-rs register_template_string.

- Handlebars + wgpu + remote-GPU patterns (private reference, patterns
  reusable): validates the handlebars-rs side and the vast.ai deployment
  shape. Patterns carried over: {{> partial}} includes for shared
  fragments, inline-able constant tables via switch statements (SHA-256
  k-values, universal across wgpu versions), default-valued template
  parameters, wgpu-on-remote-GPU sync. sha256 as a base shader
  demonstrating non-ML compute on the same dispatch surface.

Updated the WGSL codegen probe POC to reference wonnx's op set as the
porting source.
2026-06-30 13:05:54 +00:00
303b9a58e2 docs(research): split alknet-tensor into alknet-runtime + alknet-compute + alknet-tensor
Extract the shared JS+wgpu substrate (verified by the alknet-desktop POCs)
as alknet-runtime — the generalized QuickJS-NG + wgpu runtime that both
alknet-desktop (render) and alknet-compute (tensor compute) build on. Key
property driving the split: wgpu on llvmpipe is genuinely useful compute
with no physical GPU (WGSL → optimized SIMD beats JS for non-trivial
workloads), so wgpu is unconditional in the runtime rather than a feature
flag.

Reframes the original alknet-tensor architecture-summary as alknet-compute
(builds on alknet-runtime + alknet-tensor) with ShaderGenerator as a trait
(WGSL first impl, SPIR-V/GLSL/naga-IR later per wgpu multi-input-language
support). alknet-tensor/metatensor-format.md is now clearly the pure binary
format crate (no JS or wgpu dep), usable standalone by a pure-Rust model
server.

Layering: alknet-runtime depends on alknet-call (registry authority stays
per ADR-013); alknet-compute and alknet-desktop depend on alknet-runtime;
alknet-tensor is a pure-format sibling.
2026-06-30 12:44:39 +00:00
b71db99753 docs(http): add ADR-048 and websocket.md — WS carries native session, not gateway
Promote the WebSocket browser path from a section in http-server.md to a
first-class spec (websocket.md) and commit the contract-pattern decision
(ADR-048): a WS connection carries the native EventEnvelope call-protocol
session, not the HTTP gateway shape. The gateway endpoints are HTTP-only;
discovery on WS is via services/list/services/schema as ordinary call-protocol
ops; subscriptions project as native call.responded events (no SSE).

ADR-044 already decided WS as the v1 browser bidirectional path; ADR-048
clarifies the shape of what ADR-044 committed (§1 implies native session;
the ADR makes it an explicit implementer-visible rule). The from_wss adapter
(importing a remote node's ops over WS) is recorded as out-of-scope with a
concrete reversal trigger so it is not re-derived later.

Spec cleanup: http-server.md WS section collapsed to a stub pointer;
websocket.md Why section references ADRs rather than re-arguing them;
length-prefix decision made canonical (no prefix on WS — message boundary
is the delimiter); default upgrade path pinned (/alknet/call) with HTTP/2
extended CONNECT noted; indexes (README, http/README, overview) updated.
2026-06-30 12:27:00 +00:00
bfd1621b9b feat(call): add ScopedPeerEnv peer-pinned reachability (ADR-029 §4, call/scoped-peer-env) 2026-06-30 11:07:41 +00:00
5c4feff468 tasks: add call/scoped-peer-env — ScopedPeerEnv peer-pinned reachability (ADR-029 §4) 2026-06-30 10:31:19 +00:00
850ac6b7bc fix(call): remove dead test helper and unused mut (clippy -D warnings clean) 2026-06-30 10:30:52 +00:00
2a6e4c371a docs(http): resolve OQ-39; add ADRs 045-047; record pubsub prior art for WS path
OQ-39 (to_openapi published-spec versioning) resolved by ADR-045:
info.version semver tracks the gateway endpoint contract, not the
operation set — per-caller operations discovered via /search do not
bump the version. The gateway pattern (ADR-042) dissolved most of the
original churn concern.

ADR-046: assembly-layer custom HTTP routes on HttpAdapter. The HTTP
router had no documented extension point for deployment-specific
endpoints (e.g., an OAI-compatible proxy at /v1/chat/completions). Adds
extra_routes: Option<Router> at construction; raw HTTP, not operations;
default surface takes precedence on collision. The mechanism is the
one-way door; specific routes are two-way.

ADR-047: remove the direct-call POST /{service}/{op} HTTP surface. The
gateway /call is the sole invoke path — the simplified contract is a
few fixed endpoints, not a per-operation REST tree. The direct-call
surface re-introduced the 'dump the full API regardless of privs'
failure mode at the HTTP level that the gateway /search was built to
escape. ADR-036's routing decision is superseded; its non-routing
clauses (SSE, Bearer auth, /healthz, stealth, error mapping) survive.
A deployment wanting a REST-like per-operation surface builds it as a
custom route projection (ADR-046).

ADR-044 updated with the tradeoff framing (WSS is the right tool for
the call-protocol-from-browser case; WebTransport is the right tool for
the generalized ALPN-stream-proxy case we don't have yet — coexist, not
migrate) and the @alkdev/pubsub concrete prior art (the EventEnvelope
{type,id,payload} the call protocol was derived from already has a
working WebSocket client/server; the sync is a small adjustment, not a
from-scratch build).

call-protocol.md references the pubsub lineage for the
transport-agnosticism claim.
2026-06-30 09:49:25 +00:00
3327d585da docs(http): resolve OQ-40 reqwest client config — ClientWithMiddleware + retry/retry-after middleware stack
OQ-40 resolved: alknet-http owns a shared reqwest_middleware::ClientWithMiddleware
(not a bare reqwest::Client) with a two-layer middleware stack —
RetryTransientMiddleware (reqwest-retry, exponential backoff on transient
failures) + inlined RetryAfterMiddleware (from melotic/reqwest-retry-after, MIT,
~50 lines, inlined to bound the upstream's unbounded HashMap storage). The two
are complementary: reqwest-retry's default strategy does not honor Retry-After.

Hot-reload is rebuild-and-swap via ArcSwap (same pattern as
ConfigIdentityProvider, ADR-035); a rebuild drops the connection pool, which
is acceptable since a config change wanting a fresh pool is the trigger. The
three one-way constraints stand unchanged: alknet-http owns its client (no
env-var config, no shared global), credentials inject per-request from
OperationContext.capabilities, outbound TLS uses the system trust store.

Records the downstream layering boundary: the agent crate's provider SSE
normalization (the solid part of aisdk's pattern — Vercel-UI-message
normalization) sits on top of this client, consuming the reqwest::Response
stream; it does not replace the client. The aisdk core/client.rs reference for
client construction is dropped (env-var config + hand-rolled retry are the
anti-patterns discarded); the from_openapi.ts SSE normalization reference in
the forwarding-handler section is kept (separate, solid pattern).

No ADR — the decision is internal to alknet-http: the client type does not
cross crate boundaries (alknet-call never sees reqwest), the library choice is
reversible, and it does not touch the system's structure, constraints, or
cross-crate API surface.

Updates: http-adapters.md (HTTP client section rewritten, references updated,
constraints/OQ bullets updated), http-mcp.md (OQ-40 status flip), open-
questions.md (OQ-40 resolved with full config-shape table), README.md (OQ-40
folded into the existing two-way-doors bucket), and three secondary docs
(crates/http/README.md, overview.md, http-server.md) that carried stale 'open'
OQ-40 references.
2026-06-30 08:02:30 +00:00
125cb49cc4 docs(http): defer h3/WebTransport (ADR-044); browsers use WebSocket for v1
Working through the WebTransport implementation path surfaced a scope
question distinct from the hedging-as-deferral anti-pattern ADR-038 was
written to correct. Three findings drove the re-evaluation:

1. The browser bidirectional call-protocol path doesn't require
   WebTransport — WebSocket is full-duplex, EventEnvelope fits a WS
   binary message boundary cleanly, and the Dispatcher is stream-
   agnostic (ADR-012). What WebTransport gives over WebSocket (native
   multi-stream multiplexing, the ALPN-as-stream substrate) benefits the
   proxy use case, not the call protocol.
2. WebTransport is a draft standard (-07, not RFC) on an experimental
   Rust dependency stack (wtransport/h3 both self-describe as not
   production-ready). Either choice puts a draft protocol on the
   security surface of the first release.
3. The ALPN-stream-proxy (ADR-040) is speculative — its WASM parser
   consumers (browser SSH/SFTP/git clients) don't exist yet, and the
   downstream crates WebTransport deferral blocks (SSH, git, SFTP)
   expose their ALPNs natively over QUIC regardless.

This is a scope decision (per ADR-009: a decision that 'genuinely
doesn't need to be made yet because the use case isn't concrete'), not
hedging. The reversal trigger is concrete: a real deployment needing
the ALPN-stream-proxy.

ADR-038 is superseded (its anti-pattern correction stands; its specific
'h3 in scope now' decision is reversed). ADR-040 and ADR-043 are
parked, not superseded — their designs revive unchanged when WebTransport
revives, with §2 (bidirectionality) and §3 (no-PeerId overlay) of ADR-043
transferring to WebSocket for v1.

ADR-044 §5 also states the 'browser is not a peer' rationale that
ADR-034 §4 closed without arguing: peer = addressable node in the
call-protocol peer graph (stable PeerId, PeerRef::Specific-reachable,
identity stable across reconnects), not 'any endpoint that exchanges
calls during a live session.' A browser is the second but not the first
(no stable crypto identity of its own, ephemeral, not addressable from
other nodes). ADR-034 §4 and Assumption 2 are amended by reference.

The wtransport-vs-hyperium dependency question is recorded (not
resolved — WebTransport is deferred) in ADR-044 §'Research note' and
webtransport.md so the revival doesn't re-derive it: wtransport probably
isn't the right choice (axum-bridge friction — it owns its own HTTP
serving path); the hyperium stack (h3 + h3-quinn + h3-webtransport) fits
the axum integration better but its server-side WebTransport API needs
verification before commitment.

Reviewed by architecture-review subagent; all critical cross-reference
issues (ADR-034 §5 stale 'in scope' assertion, ADR-036 Context listing
h3 as implemented, webtransport.md Design Decisions table) resolved.
2026-06-30 05:55:55 +00:00
78b226d31b docs(research): revise alknet-ssh phase-0 — channel decomposition, WebTransport grounding, WASM client
Reframes the SSH scope around the channel multiplexer as the decomposition
point. Each feature (forwarding, SOCKS5, SFTP) is a channel type or a consumer
of channel types, stacking on the core — each layer functional when built,
none shipped broken. Dissolves the 'massive v1' framing that produced hedging
language proposing non-functional or half-built versions.

Three developments since the initial 2026-06-25 research changed the framing:
(1) WebTransport landed as ADRs 038/040/043, grounding SSH-over-WebTransport
as a constraint (the handler must be source-agnostic about its Connection);
(2) russh's runtime abstraction (russh-util swaps tokio::spawn for
wasm_bindgen_futures on wasm32) means the SSH *client* runs in WASM when fed a
WebTransport BiStream — the browser case is real, not speculative;
(3) the http crate intersection (ALPN-stream-proxy depends on SSH handlers
being source-agnostic) is now visible and specified.

The layered build order (1-4 stream+connection+channels+exec, then 5
forwarding, then 6 SOCKS5, then 7 SFTP) doubles as the configuration surface:
each layer beyond the core is an opt-in channel type, gating on the
default-deny ACL baseline inherited from russh.
2026-06-29 13:03:11 +00:00
0a78306686 docs(http): add ADR-043 WebTransport bidirectional ALPN substrate; fix spec drift from mid-spec pivot
A consistency review of the alknet-http specs found two classes of
issues: internal contradictions from the mid-spec pivot (the to_openapi
gateway pattern landed in prose but not in cross-references), and a
systematic client→server assumption that only holds for the OpenAPI/MCP
case leaking into the WebTransport architecture.

Class 1 (internal contradictions):
- C1: to_openapi was half-refactored — body described the ADR-042
  gateway pattern but the decisions table and ADR-036 still said
  'paths mirror /{service}/{op}'. ADR-036's to_openapi clause is now
  amended as superseded by ADR-042; the stale decisions row and README
  Principle 2 are fixed.
- C2: the axum Router route list didn't include the 5 gateway endpoints
  (/search, /schema, /call, /batch, /subscribe). Added them; clarified
  /openapi.json as the gateway description doc; added gateway paths to
  the decoy exclusion list.
- C3: ADR-034 §5 still talked about the 'h3/WebTransport deferral
  bucket' that ADR-038 eliminated. Amended §5/Consequences/References
  to drop the deferral framing (the auth-model decision stands; only
  the 'when' wording was stale).

Class 2 (one-way direction assumption):
- C4/C5/C6: the WebTransport specs framed the session as browser→hub
  one-way, when the call protocol is bidirectional and WebTransport is
  a general ALPN transport substrate. New ADR-043 reframes WebTransport
  as a bidirectional ALPN transport substrate (call protocol is the
  first/canonical target; needs no WASM parser), names the call
  protocol's bidirectionality over WebTransport sessions, and states
  the inbound no-PeerId connection-local overlay as the mirror of
  ADR-034 §2. webtransport.md is updated to reflect this framing;
  ADR-040 is repositioned (not superseded) as the substrate's non-call-
  ALPN mechanism.
- C7: the HTTP/1.1+HTTP/2 surface's one-directionality is now named as
  a lossy consequence of HTTP request/response; WebTransport is named
  as the surface that restores the bidirectional call model.
- C8: overview.md acknowledges the from/to direction model is
  OpenAPI/MCP-specific, not a call-protocol property.

A review subagent pass on ADR-043 + webtransport.md found no critical
issues; warnings W1-W3 (residual browser-as-subject framing, ADR-009
rationale in spec, opening abstract tone) and suggestions S2/S4/S5
were addressed.
2026-06-29 10:43:18 +00:00
69ebe58bab docs(http): add ADR-042 OpenAPI gateway pattern for to_openapi
The to_openapi spec was describing one OpenAPI path per alknet operation
— the inverse of from_openapi. That inverse is genuinely messy: the call
protocol's input is a flat JSON object, and generating a traditional
OpenAPI path entry (POST /fs/{path} with path param, body, query params)
requires reverse-engineering which fields are path/query/body — metadata
the call protocol doesn't carry. The three options (leaky HTTP metadata
on OperationSpec, fragile heuristics, manual annotation) are all messy.

ADR-042 replaces this with the gateway pattern (same as ADR-041 for
to_mcp): to_openapi generates 5 fixed endpoints (search, schema, call,
batch, subscribe) that gate access to the full operation registry. The
input is always a flat JSON body — no path/query/body split to
reverse-engineer. JSON Schema is already in the OperationSpec.

The per-caller API surface is the key advantage: /search is
AccessControl-filtered, so the client sees only what it can call. The
Gitea failure mode (dumping admin ops to every caller in a static
OpenAPI doc) is structurally impossible — the per-caller surface is the
default, not an afterthought. OpenAPI has no per-caller filtering
concept; the gateway pattern provides it through /search.

Gateway endpoint set:
- /search -> services/list (AccessControl-filtered, names + descriptions)
- /schema -> services/schema (full OperationSpec)
- /call -> call.requested (Query/Mutation, flat JSON body)
- /batch -> multiple call.requested (correlated IDs)
- /subscribe -> call.requested (Subscription, SSE) — the one endpoint
  the MCP gateway excludes (MCP is request/response; OpenAPI/SSE
  supports streaming)

A traditional per-operation-paths projection is additive (a deployment
that wants the nice Swagger UI builds it with HTTP-specific metadata),
not a replacement. The gateway is the default.

http-adapters.md to_openapi section rewritten: the gateway endpoint
set, per-caller filtering, error fidelity on the /call endpoint, and
the additive traditional projection. The 'Why' section adds the
flat->structured and per-caller-surface rationale.

README/overview ADR tables and the top-level README current-state note
updated for ADR-042.
2026-06-29 09:33:39 +00:00
5fc074713c docs(http): add ADR-041 MCP tool-gateway pattern for to_mcp
The to_mcp spec was describing one MCP tool per alknet operation — the
tool-bloat problem. An LLM connecting to a node with 200 operations gets
200 MCP tools dumped into its context, degrading reasoning and wasting
context budget.

ADR-041 replaces this with the tool-gateway pattern (same pattern as
opencode's memory and worktree tools): to_mcp exposes 4 fixed meta-tools
(search, schema, call, batch) that gate access to the full operation
registry. The LLM has a few tools in context, discovers operations on
demand through search + schema, then calls. Same principle as Linux's
man command — don't preload all documentation; query on demand.

Gateway tool set:
- search -> services/list (names + descriptions, AccessControl-filtered)
- schema -> services/schema (full OperationSpec for a specific op)
- call -> call.requested (Query/Mutation only, request/response)
- batch -> multiple call.requested (correlated IDs, OQ-14)

Subscription operations are excluded — MCP tool calls are
request/response by protocol design (the client blocks until
CallToolResult returns); streaming subscriptions don't fit. Subscriptions
are filtered out of search results and cannot be invoked via call.

http-mcp.md to_mcp section rewritten: the gateway tool set, Subscription
exclusion, and the service behavior (tools/list returns 4 fixed tools,
tools/call dispatches through the gateway). The 'Why' section adds the
tool-bloat rationale and the memory/worktree tool pattern that informed
the design.

README/overview ADR tables and the top-level README current-state note
updated for ADR-041.
2026-06-29 08:34:44 +00:00
398e3d512d docs(http): add ADR-040 WebTransport ALPN-stream-proxy and reframe OQ-38
The 'WebTransport proxy' concept was conflating two distinct things;
this pass separates them:

1. In-process ALPN-stream-proxy (ADR-040, in alknet-http): the h3 handler
   hands a WebTransport stream to another ALPN handler (SshAdapter,
   GitAdapter, etc.) as a Connection, so a browser with a WASM parser
   can reach any ALPN service via WebTransport. Path-based routing
   (the CONNECT path declares the target: /alknet/ssh -> SshAdapter).
   HttpAdapter gains Arc<HandlerRegistry> for the lookup. The browser's
   WASM parser implements BiStream (ADR-007) over the WebTransport
   stream. SSH-over-WebTransport is HTTPS-shaped at the network layer
   (anti-censorship: the 'VPN-like without being a VPN' use case on a
   clean foundation). russh-sftp demonstrates WASM targeting is
   feasible; SSH is the next target.

2. Standalone relay service (OQ-38, future alknet-relay crate): a full
   relay - fork of iroh-relay - with WebTransport proxy fallback for
   NAT traversal. This is infrastructure, not a mode of the h3 handler.
   OQ-38 reframed to be the standalone-relay scope question (distinct
   from the in-process proxy now resolved by ADR-040).

webtransport.md updated: three stream destinations (call protocol,
ALPN-handler proxy, other sub-protocols) with path-based routing; new
'ALPN-stream-proxy' section covering the WASM client side, auth model
(bearer token gates the session; protocol's own auth gates the
protocol session), and the HandlerRegistry reference.

README/overview ADR tables and OQ summaries updated for ADR-040.
2026-06-29 07:56:35 +00:00
ab47dac4ad docs(http): draft alknet-http architecture specs and ADRs 036-039
First speccing pass for alknet-http (HTTP interface crate: h2/http1.1/h3
server + from_openapi/to_openapi/from_mcp/to_mcp adapters).

Specs (crates/http/):
- README.md, overview.md — crate index, two-roles-in-one-crate framing,
  adapter location map, feature gates (h3, mcp), no-env-vars invariant
- http-server.md — HttpAdapter for h2/http1.1, axum over QUIC stream,
  Bearer auth, SSE projection for subscriptions, /healthz, stealth decoy
- http-adapters.md — from_openapi (reqwest) and to_openapi (projection),
  error fidelity (HTTP_<status> per ADR-023), type definitions
- http-mcp.md — from_mcp/to_mcp (feature-gated), streamable-HTTP-only
- webtransport.md — h3/WebTransport handler, browser streaming path,
  HTTP/3 request vs WebTransport session distinguished at framing layer

ADRs:
- ADR-036 HTTP-to-Call Operation Mapping (Proposed) — direct path
  mapping; to_openapi is projection, not router (the load-bearing one-way
  door from Phase 0 DH-3)
- ADR-037 MCP Stdio Transport Exclusion (Proposed) — streamable HTTP
  only; stdio is not built (RCE-vector security position)
- ADR-038 HTTP/3 and WebTransport as First-Class HTTP Transports
  (Proposed) — corrects the Phase 0 DH-2 deferral framing; h3 is in
  scope, not deferred, per ADR-009 §'What this framework is NOT'
- ADR-039 HTTP Server and Client Host Colocated in alknet-http
  (Proposed) — one crate for server + client host (shared HTTP deps,
  shared operation-spec->HTTP mapping)
- ADR-003 Amendment 1 — clarifies alknet-call is a protocol-foundation
  crate (the alknet-http -> alknet-call dependency edge)

Open questions (OQ-38, OQ-39, OQ-40 added under 'Theme: alknet-http'):
- OQ-38 WebTransport relay-as-proxy scope (genuine scope question, not
  a deferral — the decision is made when the use case becomes concrete)
- OQ-39 to_openapi published-spec versioning (one-way after first
  publication)
- OQ-40 reqwest client config and connection pooling (two-way-door)

Architecture README and overview updated with doc table, ADR table
(036-039), current-state note, and crate graph (alknet-http ->
alknet-call edge).

Reviewed by architecture-reviewer subagent: 3 critical, 4 warning, 5
suggestion issues found and fixed (missing ADR-039, WebTransport stream
routing conflation, undefined types, stale OQ-37 deferral language,
README OQ table completeness, Bearer-only attribution, cross-references,
ADR-038 ALPN quote, feature-gate placeholder, MCP temporal language).
2026-06-29 05:53:38 +00:00
dd5ccf4983 tasks: mark call/review-call-sync complete — all 17 tasks done 2026-06-28 22:29:40 +00:00
507358b285 review(call): fix fmt drift in adapter.rs and env.rs (call/review-call-sync) 2026-06-28 22:29:10 +00:00
1af81346d1 tasks: mark call/call-client-verifier-selection complete 2026-06-28 22:24:45 +00:00
c106f4a37b feat(call): wire CallClient TLS client-auth and server cert verifier selection (call/call-client-verifier-selection)
Replace AcceptAnyServerCertVerifier (a security hole for X.509) with
verifier selection by PeerEntry presence (ADR-034 §3, OQ-29):

- build_client_auth presents the Ed25519 key as an RFC 7250 raw public
  key client cert (replaces with_no_client_auth), activating the
  PeerEntry fingerprint -> peer_id resolution path on quinn.
- select_server_verifier: Some(fingerprint) -> FingerprintPinVerifier
  (fingerprint match for known peers); None -> WebPkiServerVerifier
  (CA verification for public X.509 endpoints). None + Ed25519 raw key
  fails closed at handshake (no CA to fall back to).
- FingerprintPinVerifier matches ed25519:<hex> (raw key extraction) and
  SHA256:<hex> (DER hash); verifies handshake signatures via
  verify_tls13_signature_with_raw_key / verify_tls12/13_signature.
- Extract shared fingerprint logic into alknet_core::fingerprint (pub
  module) reused by endpoint (server-side) and call_client (client-side).
- remote_identity: None is load-bearing (not defaulted to placeholder).
- Integration tests updated to pin the self-signed server cert
  fingerprint (the known-peer path).
2026-06-28 22:24:09 +00:00
d9227b8123 tasks: mark call/from-call-forwarded-for complete 2026-06-28 22:22:20 +00:00
f5fede2758 feat(call): wire from_call forwarded_for and peer-keyed collision (call/from-call-forwarded-for) 2026-06-28 22:21:52 +00:00
95b06fc07f tasks: mark call/dispatch-peer-identity complete 2026-06-28 22:21:44 +00:00
7f9e5828b9 feat(call): wire dispatch_requested to resolve peer Identity, ACL gate, and forwarded_for (call/dispatch-peer-identity) 2026-06-28 22:21:23 +00:00
b7acd1d69d tasks: mark call/operation-env-invoke-peer complete 2026-06-28 22:10:06 +00:00
d04cb9c125 feat(call): add invoke_peer/peer_contains/PeerRef to OperationEnv for peer-keyed routing (call/operation-env-invoke-peer) 2026-06-28 22:09:35 +00:00
a9792b4010 tasks: mark call/operation-context-forwarded-for complete 2026-06-28 22:08:53 +00:00
5d6a943ad4 feat(call): add forwarded_for field to OperationContext (call/operation-context-forwarded-for) 2026-06-28 22:08:35 +00:00
37e430b09d tasks: mark call/services-list-accesscontrol-filtered complete 2026-06-28 22:03:40 +00:00
877c923244 feat(call): filter services/list by AccessControl and add services/list-peers opt-in (call/services-list-accesscontrol-filtered) 2026-06-28 22:03:29 +00:00
2902ccff18 tasks: mark call/peer-composite-env complete 2026-06-28 22:02:47 +00:00
e8219fa550 feat(call): replace CompositeOperationEnv with peer-keyed PeerCompositeEnv (call/peer-composite-env) 2026-06-28 22:02:17 +00:00
1aeb634a2d tasks: mark call/retire-remote-safe complete 2026-06-28 21:53:20 +00:00
4490bc251f feat(call): retire remote_safe/trusted_peer/RemoteFilter (call/retire-remote-safe) 2026-06-28 21:52:57 +00:00
fb510d0887 tasks: mark core/review-core-sync complete 2026-06-28 21:44:26 +00:00
c63c6ec471 review(core): ADR-029/030/031/034/035 sync verified conformant (core/review-core-sync) 2026-06-28 21:44:18 +00:00
3eadd47618 tasks: mark core/fingerprint-normalization complete 2026-06-28 21:41:02 +00:00
ea31200d17 feat(core): normalize Ed25519 raw-key SPKI fingerprint to ed25519:hex (core/fingerprint-normalization) 2026-06-28 21:40:45 +00:00
3c7cfe5446 tasks: mark core/config-identity-provider-peerentry complete 2026-06-28 21:40:36 +00:00
50abd346a4 feat(core): wire ConfigIdentityProvider to PeerEntry multi-credential path (core/config-identity-provider-peerentry) 2026-06-28 21:40:27 +00:00
e980fcc27f tasks: mark core/identity-store-trait complete 2026-06-28 21:37:26 +00:00
74c1e8d42c feat(core): add IdentityStore async write trait extending IdentityProvider (core/identity-store-trait) 2026-06-28 21:36:14 +00:00
e0ecc9e370 tasks: mark core/three-remote-roles-docs complete 2026-06-28 21:35:50 +00:00
221a64b2b4 feat(core): document three remote roles and client-side verifier selection (core/three-remote-roles-docs) 2026-06-28 21:35:31 +00:00
4c31f19c9c tasks: mark core/peer-entry-model and core/credential-store-trait complete 2026-06-28 21:30:10 +00:00
f3702196e4 feat(core): add CredentialStore trait, InMemoryCredentialStore, EncryptedData mirror, StoreError (core/credential-store-trait) 2026-06-28 21:29:39 +00:00
d1b8811432 feat(core): add PeerEntry struct and replace AuthPolicy.authorized_fingerprints with peers (core/peer-entry-model) 2026-06-28 21:27:42 +00:00
df355c53a9 tasks: decompose ADR-029/030/031/032/034/035 source sync into 17 tasks
Decompose the source-to-spec sync for the core and call crates into atomic,
dependency-ordered tasks for implementation agents:

Core (7 tasks + review):
- peer-entry-model: PeerEntry struct, AuthPolicy.peers (ADR-030 keystone)
- credential-store-trait: CredentialStore/InMemoryCredentialStore/StoreError (ADR-031/035)
- identity-store-trait: IdentityStore async write trait (ADR-035)
- config-identity-provider-peerentry: ConfigIdentityProvider PeerEntry resolution (ADR-030)
- fingerprint-normalization: ed25519:hex for raw keys across quinn/iroh (ADR-030 §6)
- three-remote-roles-docs: document ADR-034 roles and verifier selection
- review-core-sync: phase gate before call consumes new identity semantics

Call (9 tasks + review):
- retire-remote-safe: remove ADR-028 machinery, AccessControl is the gate (ADR-029 §3)
- operation-context-forwarded-for: forwarded_for field, wire-ingress only (ADR-032)
- peer-composite-env: PeerCompositeEnv, PeerId=Identity.id, remove UUID (ADR-029/030)
- operation-env-invoke-peer: invoke_peer/peer_contains/PeerRef (ADR-029 §2)
- services-list-accesscontrol-filtered: AccessControl filter, list-peers opt-in (ADR-029 §6)
- call-client-verifier-selection: TLS client-auth, verifier by PeerEntry (OQ-29, ADR-034)
- from-call-forwarded-for: populate forwarded_for, peer-keyed registration (ADR-029 §5, ADR-032)
- dispatch-peer-identity: AccessControl::check(peer_identity), PeerId from resolution (ADR-029 §3, ADR-030 §5)
- review-call-sync: phase gate for the call sync

Validated: 58 tasks, no cycles, logical topo order, two review checkpoints.
2026-06-28 21:08:41 +00:00
4a52779460 docs(arch): amend call specs for ADR-029/030/032/034 — peer-keyed routing, PeerEntry, forwarded-for, three roles
Sync the call crate specs to the accepted ADRs 029-034:
- operation-registry: PeerCompositeEnv (peer-keyed overlays), invoke_peer/
  PeerRef routing, retire remote_safe/trusted_peer, AccessControl-based peer
  auth, forwarded_for on OperationContext (ADR-029/030/032)
- call-protocol: peer-keyed compose_root_env, forwarded_for in call.requested
  payload, build_root_context forwarded_for parameter (ADR-029/032)
- client-and-adapters: CallClient verifier selection by PeerEntry presence,
  remote_identity: None load-bearing, three remote roles (ADR-034)
- README: ADR-029/030/032/034 in applicable ADRs table
2026-06-28 21:08:26 +00:00
0de2cebb1d docs(arch): ADR-035 — concrete persistence adapter shapes, resolve OQ-36
Commits the concrete adapter shape deferred by ADR-033: read-sync /
write-async split with honker NOTIFY/LISTEN for no-restart cache
invalidation, against SQLite, in a separate alknet-store-sqlite crate.

Two constraints drive the design: (1) the hot-path read trait
(IdentityProvider::resolve_from_fingerprint, CredentialStore::get) is
sync — called in the accept loop, no .await — so a SQLite-backed
adapter must cache in memory and serve sync reads from the cache; (2)
auth changes must take effect without a restart (an early issue the
project already fixed for ConfigIdentityProvider via ArcSwap config
reload). honker's SQLite NOTIFY/LISTEN (single-digit-ms wake, no
polling) is the cache-invalidation mechanism that makes both hold:
write commits to SQLite + emits NOTIFY, the running process's LISTEN
wakes, the in-memory index reloads and atomically swaps, the next
read sees the new state. Same ArcSwap-reload pattern as config,
generalized from 'config file is source of truth' to 'SQLite is
source of truth, honker signals when it changed.'

New async IdentityStore write trait (put_peer / update_peer /
remove_peer) extends the sync IdentityProvider read trait for peer
mutations. ConfigIdentityProvider does NOT implement it (config
reload is its write path — a posture enforced by the absence of a
backend, not a type-system constraint); SqliteIdentityProvider
implements both. CredentialStore::put/delete refined to async (within
ADR-031's one-way door — the contract was get/put/delete keyed by
provider persisting EncryptedData never decrypting; sync-vs-async was
unspecified). CredentialStoreError renamed to shared StoreError
covering both traits.

alknet-store-sqlite is one crate implementing both IdentityStore and
CredentialStore with shared SQLite connection + honker LISTEN infra
(splitting later is a two-way door). Schema shape committed (one row
per PeerEntry with JSON columns for fingerprints/scopes/resources;
one row per EncryptedData blob keyed by provider); exact DDL is an
implementation-detail two-way door in the adapter crate. The keypal
adapter-factory pattern is intentionally not ported to Rust (runtime
column-mapping is a TS affordance; in Rust each adapter is a concrete
type, cross-cutting concerns are a shared helper module).

Amends ADR-031 (put/delete async refinement, StoreError rename),
ADR-033 (concrete adapter shape now specified, two-crate framing
collapsed to one), ADR-034 (OQ-36 now resolved), auth.md (IdentityStore
section, cache-invalidation summary, OQ-36 reference), config.md (two
write paths note), and the OQ-36/OQ-34 entries in open-questions.md.
Review fixed 4 criticals (error-type name divergence, duplicate
IdentityProvider sketch, upsert/Duplicate ambiguity, 'shape unchanged'
contradiction), 7 warnings, 5 suggestions.
2026-06-28 11:10:31 +00:00
6cc8715ccf docs(arch): ADR-034 — outgoing-only X.509 and three peer roles, resolve OQ-37
Untangles the conflation of three distinct remote roles under 'X.509
endpoint': (1) public X.509 endpoint — a remote HTTPS/call-over-TLS
server the local node is a client of (no PeerEntry, no PeerId, not in
the peer graph; CA verification + bearer token); (2) transport relay —
iroh's DERP-equivalent, infrastructure, not an alknet peer; (3) hub /
hosting node — an alknet peer that also exposes a public domain + X.509
for browsers (mixed-fingerprint PeerEntry, already supported by
ADR-030).

The load-bearing one-way door is the client-side verifier selection
rule: known peer (PeerEntry present) → fingerprint pin; unknown X.509
remote → CA verification (WebPkiServerVerifier); unknown Ed25519
remote → fails closed. This closes the AcceptAnyServerCertVerifier
security hole OQ-29 flagged, with the peer-model criterion (PeerEntry
presence) made explicit. The 'make PeerEntry symmetric' instinct is
rejected — pure-client connections to public APIs have no stable
logical identity to pin.

Documents that CallCredentials.remote_identity: None is load-bearing
(None = public X.509 endpoint → CA path, not a missing field; Some =
known peer → fingerprint pin), closing a subtle gap where an
implementer could have defaulted to a placeholder or treated None as
skip-verify.

Records WebTransport relay-as-proxy (deferred with h3/WebTransport,
new OQ-HTTP-07) and on-chain/smart-contract peer discovery (fits the
OQ-36 repo/adapter pattern, no auth-model change) so they aren't lost.

Amends auth.md and client-and-adapters.md with the three-role naming,
the verifier selection rule, and the Option semantics; updates OQ-37
to resolved in open-questions.md, README.md, and both crate READMEs.
2026-06-28 10:47:49 +00:00
3f011cbb82 docs(arch): tighten door-type framing — reversal cost, not deferral
ADR-009, open-questions.md, and the architect agent spec all had the same
conflation: 'two-way door' was phrased as 'can be decided during
implementation,' which reads as 'defer the decision.' That's not what it
means. A two-way door is a decision you make now and can revert later if
wrong — it's about reversal cost, not urgency.

ADR-009: add §'What this framework is NOT' — explicitly separates door
type (reversal cost) from deferral (scope management). State that
architecture decisions are the architect's regardless of door type.
Reword the two-way-door process from 'can be decided during
implementation' to 'pick the simplest option that works, implement it,
revert if needed.'

open-questions.md: reword the header to clarify door type describes
reversal cost, not urgency. Add 'Door type is separate from whether a
decision is made.'

architect.md: add Key Principle #8 (decisions are made, not deferred),
a new 'Door Types and Decision Urgency' section, and two new anti-patterns
(#8: door type as deferral, #9: hedging language in resolved decisions).
2026-06-28 09:19:10 +00:00
7d812af8f4 docs(arch): multi-credential PeerEntry, resolve OQ-29, dissolve OQ-35, add OQ-37
Amend ADR-030 with three changes from the auth-type analysis:

1. PeerEntry is now multi-credential: fingerprints: Vec<String> (Ed25519
   and/or X.509) + auth_token_hash: Option<String> (bearer token). All
   resolve to the same peer_id. A peer that authenticates via Ed25519
   today and via auth_token tomorrow gets the same PeerId. The 'peer
   bearer vs auth bearer' distinction was wrong — the correct framing is
   the three credential types (Ed25519, X.509, bearer token) and whether
   the token needs a stable logical id across rotation (PeerEntry) or not
   (ApiKeyEntry).

2. Fingerprint normalization (§6): quinn extracts the raw Ed25519 public
   key from the SPKI cert and formats as ed25519:<hex>, matching iroh.
   The same key has the same fingerprint regardless of transport. X.509
   fingerprints stay as SHA256:<hex of DER>. This also simplifies the
   coming WebTransport relay work.

3. The 'API keys' section is replaced with 'Bearer tokens' — correctly
   framing the three auth types and the two bearer-token paths
   (PeerEntry.auth_token_hash vs ApiKeyEntry).

Resolve OQ-29 (CallClient TLS client-auth): wire quinn client-auth (present
Ed25519 key as raw public key client cert — the server-side extraction
already works); key-type-aware server cert verification (raw key =
fingerprint match, X.509 = CA verification via WebPkiServerVerifier —
AcceptAnyServerCertVerifier is only safe for raw keys); fingerprint
normalization. The iroh path already works (RFC 7250 raw keys, both sides
exchange automatically); the gap was quinn-only.

Dissolve OQ-35: the 'API key asymmetry' framing was wrong. PeerEntry
supports multiple credential paths; ApiKeyEntry is for tokens that ARE the
identity.

Add OQ-37: X.509 outgoing-only case — the three auth types and how X.509
server identity fits the peer model. Not blocking the ADR-029 migration;
downstream (HTTP crate phase).

Update auth.md, config.md, client-and-adapters.md, call/README.md,
core/README.md, open-questions.md, README.md, and call_client.rs source
comment.

Workspace green: 326 tests pass, build clean.
2026-06-28 08:49:36 +00:00
1d94aaea51 docs(arch): resolve call-crate OQs, promote OQ-29 to load-bearing on ADR-030
Resolve the call-crate open questions where the decision is made —
OQ-27 (auto-re-import), OQ-28 (same-peer collision = error), OQ-30
(PeerRef::Any insertion-order first-match), OQ-31 (services/list-peers
opt-in). These were previously marked 'open' with 'v1' hedging language
despite having a decided default. What remains (refresh(), richer routing,
services/list-peers the op) is genuine feature addition, not unmade
architecture.

Reframe OQ-32 (multi-hop) as a feature extension rather than a 'v1'
deferral — the one-hop model is the architectural commitment; extending
to multi-hop doesn't break downstream.

Promote OQ-29 (CallClient TLS client-auth) from medium to high priority
and surface its real interaction with ADR-030. Previously framed as
'additive — two-way-door remainder,' but ADR-030's PeerEntry fingerprint
→ peer_id resolution requires the client to present a TLS client cert.
With with_no_client_auth(), no fingerprint is extracted, the PeerEntry
path is dormant, and PeerCompositeEnv keys on None or the API-key prefix
instead of the stable peer_id. This is the activation path for ADR-030's
primary use case, not an additive feature. Three options laid out: (a)
wire client-auth with the ADR-029 migration, (b) ship token-only and
switch later (the 'compounds into a mess' path), (c) extend PeerEntry
to cover auth_token-based identity. Requires a decision before the
migration lands.

Clarify OQ-36 (concrete adapter shapes): the trait shapes and in-memory
adapters ship with core — the deferral is only for the persistence
adapters (SQLite, etc.). The in-memory adapters are real implementations
of a full repo pattern, not stubs.

Update call_client.rs source comment to reference OQ-29 instead of the
'v1' / 'two-way-door remainder' framing.

Workspace green: 326 tests pass, build clean.
2026-06-28 05:35:52 +00:00
f224ea998c docs(arch): ADR-030..033 — repo/adapter pattern, PeerEntry, CredentialStore, forwarded-for
Land the storage and auth strategy research (findings.md) as four
accepted ADRs and amend the core and call specs to match:

- ADR-030: PeerEntry and Identity.id decoupling. Replaces
  authorized_fingerprints with peers: Vec<PeerEntry>; Identity.id becomes
  the stable peer_id, decoupled from the rotating fingerprint. Supersedes
  ADR-029 Assumption 1's UUID source (one-way door preserved, source
  changes). Resolves OQ-33 and the storage-boundary half of OQ-34. Records
  the API-key asymmetry as deliberate (OQ-35).

- ADR-031: CredentialStore repo trait + InMemoryCredentialStore default
  adapter in core. Second repo trait alongside IdentityProvider. Vault
  encrypts; the store persists the EncryptedData blob; assembly layer
  loads into Capabilities. EncryptedData core mirror includes salt for
  wire-format compat.

- ADR-032: Forwarded-for identity. forwarded_for field on call.requested
  and OperationContext — metadata only, never read by AccessControl::check
  (enforced structurally via the check signature). The from_call handler
  populates it. Wire-format one-way door, folded into the ADR-029
  migration window.

- ADR-033: Storage boundary and repo/adapter pattern. Core defines repo
  traits + in-memory defaults; persistence adapters are separate crates;
  assembly layer wires. Resolves OQ-34. Concrete adapter shapes deferred
  for exploration (OQ-36).

Amends auth.md, config.md, operation-registry.md, client-and-adapters.md,
open-questions.md, README.md, crates/core/README.md. Marks ADR-029
Accepted (Assumption 1 carries the ADR-030 superseded note). Marks the
research findings doc reviewed.
2026-06-27 12:12:25 +00:00
347bff257c docs(research): rewrite storage/auth strategy — concrete repo/adapter design, no deferrals
Reworks the storage strategy doc to commit to concrete design, replacing
the 'when storage arrives' / 'future' / 'later' framing that was putting off
important work.

Key changes from the previous draft:
- §4 (Repo/Adapter Pattern): now an explicit design with the trait contracts
  (IdentityProvider, CredentialStore), the adapter contracts
  (ConfigIdentityProvider with PeerEntry update, SqliteIdentityProvider,
  InMemoryCredentialStore, SqliteCredentialStore), and the concrete table
  schemas. Not a pattern description — a design commitment.
- §4: PeerEntry config model — AuthPolicy gains peers: Vec<PeerEntry>
  replacing authorized_fingerprints: HashSet<String>. This is the
  id-fingerprint decoupling (OQ-33) done as a config change, not a storage
  change. ConfigIdentityProvider resolves fingerprint → PeerEntry →
  Identity { id: peer_id } (stable, not the fingerprint).
- §7 (Decomposition): the 'what goes where' table now has a Status column
  (exists / needs adding / needs building / needs PeerEntry update) instead
  of 'future'. The crate graph is a concrete build plan.
- §10 (Build Order): replaces 'What This Means for the Immediate Path' (which
  had 'when storage arrives' framing) with a 4-tier dependency-driven build
  order. Tier 1 = core repo traits + PeerEntry config model. Tier 2 = SQLite
  adapters. Tier 3 = ADR-029 migration + forwarded_for. Tier 4 = alknet-graphs
  (built when a graph-shaped problem exists, not speculatively).
- §10: explicit 'What does NOT get built (dropped, not deferred)' section —
  multi-tenant, accounts/orgs, secrets module, single storage crate are
  dropped, not deferred.
- All 'future' / 'when X arrives' / 'v1' / 'phase n' language removed for
  things that are needed. The only 'when X is needed' language remaining is
  for genuinely non-existent problems (ACL delegation, workflows, taskgraph)
  — those are built when the problem exists, not speculatively.
2026-06-27 10:36:07 +00:00
19d010cf73 docs(research): storage and auth strategy — repo pattern, per-node ACL, SQLite+honker, metagraph-as-tool
Synthesizes the multi-thread discussion that surfaced during the peer-graph
routing research (ADR-029) and OQ-33/34 resolution. Three separate threads
(peer identity, filesystem POC, old storage spec) converged on the same
question: where does persistent state live in the alknet crate graph, and
what's the shared infrastructure for it.

Key commitments documented:
- SQLite + honker is the foundation (pattern, not a crate — ~20 lines per
  consumer). The metagraph is one tool built on it, for graph-shaped
  problems. Direct tables are another tool, for table-shaped problems.
- IdentityProvider is the auth repo trait (already exists in core, make the
  pattern explicit). Adapters implement it (Config, SQLite, future
  Redis/remote/automerge). PeerStore is adapter-internal, not core.
- Per-node ACL, no 'trusted' flag. Each node authorizes its direct callers
  via AccessControl::check(identity). No global ACL, no replication. The
  hub authorizes the user; the spoke authorizes the hub. Same mechanism.
- Forwarded-for identity as metadata, not authority. The from_call handler
  includes the original caller's identity in the call payload; the spoke's
  ACL authorizes the hub (direct caller), never the forwarded_for. The ACL
  check signature prevents misuse.
- The ACL check stays table-shaped (flat scope match); the delegation graph
  (future) produces effective scopes at resolution time. They compose at the
  IdentityProvider boundary.
- The hub proxy tangle: ACL (authorize), bucket routing (operation input),
  peer routing (PeerRef) are three separate layers. Bucket-level
  authorization is handler logic, not protocol logic.

What the old spec had that's dropped: multi-tenant (each tenant gets own
setup), secrets module (replaced by vault), metagraph-as-foundation (demoted
to tool), single storage crate (split by concern), accounts/orgs (deferred —
v1 is a peers table).

Reference: kepal (/workspace/keypal) — TypeScript repo-pattern example
(Storage interface + adapters) that alknet's IdentityProvider follows.
2026-06-27 10:02:26 +00:00
99c6dd9483 docs(arch): resolve OQ-26 (AdapterError variants) + OQ-33 (PeerId = logical id) + OQ-34 (persistent peer registry)
OQ-26 (resolved): AdapterError variants decided — DiscoveryFailed,
SchemaParse, Transport, Unauthorized, SamePeerCollision (replaces flat
Conflict per ADR-029 §5). #[non_exhaustive] for downstream extension.
Two-way door; the initial set is the code's return type.

OQ-33 (resolved): PeerId is a logical identifier, NOT Identity.id. The
research's v1 default (PeerId = fingerprint) is overridden: coupling PeerId
to crypto material breaks every in-flight PeerRef::Specific and every ACL
entry on key rotation. v1 source is a connection-assigned UUID — a
no-storage workaround that works for the immediate use case (head→workers,
reconnect produces fresh PeerRef, in-flight gets NOT_FOUND which is correct).
The one-way door: PeerId is logical, not crypto — this determines
PeerCompositeEnv key type and PeerRef::Specific payload. The id source
(UUID vs configured name vs peer registry) is the two-way-door remainder.

OQ-34 (new): the storage dimension OQ-33 surfaced. The core crates are
deliberately DB-free (smaller, fewer deps, simpler testing) — this served
local-only state (vault, registry) well, but peer identity is the first
cross-node state that wants persistence. The real solution (a persistent
peer registry mapping stable logical name → current crypto material,
surviving key rotation) is not a v1 blocker (UUID works), but tracked so the
no-DB posture's limit is deliberate, not accidental. The storage boundary
(core gets a PeerRegistry trait vs stays storage-free) is the one-way door;
the backend choice is two-way. Key-rotation/ACL note: decoupling PeerId from
crypto keeps the door open for ACL entries that persist across key rotation
— when the peer registry is built, ACLs key on the logical name and key
rotation becomes vault-only with no remote-side ACL update.
2026-06-27 06:34:35 +00:00
77eb35a8a5 docs(arch): ADR-029 peer-graph routing model — supersedes ADR-028
ADR-028's remote_safe/trusted_peer was a parallel, weaker authorization system
that duplicated the existing AccessControl/Identity machinery and couldn't
express the head→N-workers pattern (the primary use case). The flat-namespace
single-peer overlay model (one connection layer in CompositeOperationEnv)
structurally breaks the moment a head has two workers both exposing
/container/exec.

ADR-029 replaces it with:
- Peer-keyed overlays: PeerCompositeEnv { connections: HashMap<PeerId, ...> }
  replaces CompositeOperationEnv's singular connection layer. A head node
  routes invoke_peer() to the right peer via PeerRef::Specific / PeerRef::Any.
- AccessControl-based peer authorization: the existing AccessControl::check
  (peer_identity) gates peer calls — the same mechanism that gates every other
  call. remote_safe/trusted_peer/RemoteFilter/list_operations_peer_scoped/
  services_list_handler_peer_scoped are retired. The op's AccessControl IS the
  peer-authorization policy; no parallel system.
- ScopedPeerEnv: peer-qualified reachability (peer-pinned allowlist) replaces
  from_call's namespace_prefix as the disambiguation mechanism. Cross-peer
  collision dissolves (separate sub-overlays); same-peer collision stays error.
- services/list-peers opt-in for peer-attributed re-export listing.

POC-validated against real types (scratch module written, type-checked,
removed; build clean, 207 tests pass). Petgraph not needed for v1 (one-hop,
shallow); nested HashMap suffices; extends to multi-hop without redesign (OQ-32).

OQ impact: OQ-25 dissolved (no marking); OQ-28 cross-peer dissolved / same-peer
stays; OQ-26/27/29 stay; new OQ-30 (Any routing policy), OQ-31 (list-peers
semantics), OQ-32 (multi-hop federation).

Research: docs/research/alknet-call-peer-routing/findings.md (POC shapes,
prior art — Ray.io actors, Dapr service invocation, full ADR draft).
ADR-028 marked Superseded; ADR-017 DC-1 amendment updated to point at ADR-029.
2026-06-27 06:04:19 +00:00
f9c0ab092b docs(arch): sync call-completion specs with implementation — Dispatcher/RemoteFilter, ClientError, OQ-29
Post-implementation spec sync after the call-completion batch landed
(commits e4a2594..a3825f5). The sub-agent review flagged no spec drift, but
comparing the implemented types against the spec sketches surfaced five
details the specs didn't name — filled in here so the spec matches what was
built:

- client-and-adapters.md: name the shared Dispatcher (protocol/dispatch.rs)
  + RemoteFilter mechanism that enforces ADR-028's default-deny at dispatch
  time (the load-bearing security gate — checks remote_safe before building
  context, before any capability material reaches the handler). Add
  ClientError/RemoteIdentity types, the spawn_dispatch lower-level API, and
  the services_list_handler_peer_scoped wiring (the assembly layer must
  register the peer-scoped services/list handler for a CallClient's registry,
  not the plain one). Record the v1 TLS client-auth gap (AcceptAnyServerCertVerifier,
  with_no_client_auth) as OQ-29.
- call-protocol.md: point the adapter dispatch-loop description at the shared
  Dispatcher (dispatch.rs) so readers find the mechanism ADR-017 §1 commits to.
- open-questions.md: OQ-29 — CallClient TLS client-auth + remote-identity
  verification is a two-way-door remainder; the no-env-vars invariant is
  unaffected (auth_token flows via call-protocol payload, not TLS).
- READMEs: current-state now reflects completion done + reviewed (207 lib +
  2 integration tests); OQ-29 added to both OQ summaries.
2026-06-26 13:42:42 +00:00
2fe471ad4e docs(tasks): mark call/review-completion complete with review findings
Records the conformance review for the alknet-call client/adapter
completion batch. All 14 acceptance criteria pass:

- CallClient, from_call, OperationAdapter, from_jsonschema match
  client-and-adapters.md.
- Peer-scoped default-deny (ADR-028) enforced: non-remote-safe ops return
  NOT_FOUND before capabilities are populated (the load-bearing security
  assertion, verified by three capability-exposure tests).
- Shared dispatch loop is genuinely shared (single Dispatcher; CallAdapter
  and CallClient both delegate to run_loop).
- No-env-vars invariant holds (no std::env::var reads; credentials from
  Capabilities).
- Adapter location map respected (no HTTP deps in alknet-call).
- OQ-25..28 two-way-door remainders recorded with v1 defaults.
- No spec drift requiring amendment.
- 207 lib + 2 integration tests pass; clippy + fmt clean.

This closes the call-completion batch. Unblocks every downstream consumer
(runner, container service, bilateral exchange, NAPI, agent cross-node
dispatch) and alknet-http Phase 1 (OperationAdapter trait).

Refs: tasks/call/review-completion.md
2026-06-26 13:27:08 +00:00
a3825f57cf feat(call): from_call adapter — discover + register remote ops (ADR-017 §3)
The #2 gap in alknet-call: discovers the remote peer's External operations
via services/list + services/schema and registers them in the connection's
Layer 2 overlay as FromCall-provenance leaves with forwarding handlers. The
discovery mechanism was already implemented in registry/discovery.rs;
from_call is the client-side consumer of that API.

src/client/from_call.rs:
- from_call(connection, FromCallConfig) -> Result<Vec<HandlerRegistration>,
  AdapterError>. Calls services/list then services/schema for each op,
  rebuilds OperationSpec from the schema JSON (parsing op_type, visibility,
  error_schemas, access_control), constructs a forwarding handler that calls
  the remote op via CallConnection::call(), and returns FromCall-provenance
  bundles (composition_authority: None, scoped_env: None, empty capabilities,
  remote_safe: false per ADR-028 §4).
- FromCallConfig { namespace_prefix: Option<String>, operation_filter:
  Option<HashSet<String>> } with builder methods.
- v1 defaults (two-way doors recorded in client-and-adapters.md):
  - error-on-collision (DC-3/OQ-28): applying the (possibly empty) prefix
    produces a name already seen -> AdapterError::Conflict, not silent
    overwrite.
  - auto-on-reconnect (DC-2/OQ-27): the overlay is per-connection (Layer 2,
    ADR-024), so re-import on reconnect is naturally scoped; the assembly
    layer calls from_call immediately after connect().
- Forwarding handler captures an Arc<CallConnection> and, on invocation,
  calls the remote op and returns its ResponseEnvelope. The
  parent_request_id participates in the cross-node abort cascade
  (ADR-016 §6) — if the parent is aborted, the cascade reaches this handler
  which sends call.aborted to the remote node; cross-node abort is
  transparent.
- Trust is transitive (recorded in spec): a from_call-imported op executes
  the remote node's code; scoped_env bounds which ops are reachable, not
  what they do.

OperationContext.internal is now pub (was pub(crate)) so downstream
consumers (assembly layer, integration tests) can construct contexts for
overlay-env dispatch.

Tests (207 lib + 2 integration):
- Unit: rebuild_spec name/prefix/op_type/visibility/error_schemas/acl;
  unknown op_type -> SchemaParse; missing op_type -> SchemaParse;
  FromCallConfig builder; from_call against a mock connection returns
  DiscoveryFailed (no transport); FromCall provenance + leaf fields + remote_safe false.
- Integration (tests/two_node_call.rs): from_call over a real QUIC loopback
  — CallClient connects, from_call discovers server/echo, registers the
  bundle in the overlay, and the forwarding handler round-trips an input
  through the overlay env to the remote op and back.

clippy + fmt + test all green.

Refs: tasks/call/client/from-call.md
Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §3, §6
Refs: docs/architecture/crates/call/client-and-adapters.md §from_call
2026-06-26 13:25:13 +00:00
4bf897f5ab feat(call): CallClient + shared dispatch loop + peer-scoped default-deny (ADR-017, ADR-028)
The #1 gap in alknet-call: the outbound connection opener. Every downstream
consumer (runner, container service, bilateral exchange, NAPI, agent
cross-node dispatch) is blocked on it.

Shared dispatch loop (ADR-017 §1 — the architectural commitment that keeps
CallClient from becoming a parallel protocol implementation):
- Extracts the accept-path dispatch (sweeper, accept_bi loop, handle_stream,
  dispatch_requested, build_root_context, compose_root_env, fail_all on
  close) out of CallAdapter into a new protocol/dispatch.rs Dispatcher struct.
  Both CallAdapter::handle and CallClient::connect produce a CallConnection
  and hand it to Dispatcher::run_loop — the loop is genuinely shared
  (refactored, not duplicated).
- CallAdapter keeps its public API and test-facing wrappers (pub(crate),
  #[cfg(test)]-gated) that delegate to the Dispatcher.

Peer-scoped default-deny (ADR-028 — the one-way-door security dimension):
- RemoteFilter { trusted_peer: bool } on the Dispatcher. In default-deny
  mode (CallClient::new), an incoming call to an op with remote_safe: false
  returns NOT_FOUND *before* any capability material reaches the handler —
  a remote peer's call must not populate OperationContext.capabilities from
  the local registration bundle unless the op is explicitly remote-safe
  (ADR-028 Context). Trusted-peer mode (CallClient::trusted_peer, explicit
  opt-in) bypasses the filter.
- The accept path (CallAdapter) uses RemoteFilter::trusted() by convention: a
  direct QUIC client is not a filtered CallClient peer in the ADR-028 sense.
- OperationRegistry::list_operations_peer_scoped(trusted_peer) +
  services_list_handler_peer_scoped for the CallClient's services/list
  serving path (ADR-028 Assumption 2: a peer should not see ops it cannot
  call, so discovery and dispatch filters agree).

CallClient (src/client/call_client.rs):
- CallClient { registry, identity_provider, trusted_peer: bool }.
- new() default-deny; trusted_peer() explicit opt-in (ADR-028 §3).
- connect(addr, CallCredentials) dials QUIC on ALPN alknet/call (quinn
  feature), spawns Dispatcher::run_loop, returns a live CallConnection.
- spawn_dispatch(connection) shared path for connect + tests.
- CallCredentials { tls_identity, auth_token, remote_identity } — all from
  Capabilities (ADR-014), never env vars (no-env-vars invariant). v1
  connects without client-auth TLS identity (server uses
  AcceptAnyCertVerifier); RawKey client-auth is a two-way-door remainder.
- RemoteIdentity { fingerprint } — concrete shape is a two-way door (OQ-25
  remainder); the one-way constraint is it comes from Capabilities.
- ClientError { Transport, TlsSetup, ConnectionClosed }.
- CallConnection is now Clone (shares the inner Arcs) so connect can hand
  the caller a live clone while the dispatcher task keeps its clone.

Tests (199 lib + 1 integration):
- Unit: default-deny NOT_FOUND for non-remote-safe; remote_safe dispatches;
  trusted-peer dispatches all External; default-deny does NOT populate
  capabilities (the load-bearing security assertion — verified by a handler
  that inspects context.capabilities and the fact that the handler is never
  reached for non-remote-safe ops); remote_safe op populates capabilities;
  services/list peer-scoped hide/trusted variants; CallClient constructors;
  CallCredentials builder; Send+Sync.
- Integration (tests/two_node_call.rs): real QUIC loopback — CallAdapter
  server (self-signed cert via rcgen) accepts, CallClient connects,
  client.call() round-trips to server/echo. Proves the connect path +
  shared dispatch loop work end-to-end.

clippy + fmt + test all green.

Refs: tasks/call/client/call-client.md
Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §1, §2, §7
Refs: docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md
Refs: docs/architecture/crates/call/client-and-adapters.md
2026-06-26 13:19:15 +00:00
404d00ae1a style(call): apply rustfmt to connection.rs and registration.rs
Pre-existing fmt drift in two files touched during the call-completion
batch (remote_safe field, dispatch helpers). Brings cargo fmt --check
clean for the review gate.
2026-06-26 12:57:14 +00:00
1e5f94b06b feat(call): OperationAdapter trait + AdapterError + from_jsonschema (ADR-017 §5)
- client module: defines the async OperationAdapter trait
  (import() -> Result<Vec<HandlerRegistration>, AdapterError>) and the
  #[non_exhaustive] AdapterError enum (string-message payloads: DiscoveryFailed,
  SchemaParse, Transport, Unauthorized, Conflict). The trait lives in alknet-call
  where the types live; implementations live with their transport deps.
- from_jsonschema: schema-only registration producing a FromJsonSchema-provenance
  HandlerRegistration with no real handler (placeholder errors if invoked),
  None authority/scoped_env, empty capabilities, remote_safe false (ADR-028 §4).
  Implements OperationAdapter; malformed (non-object) schema returns
  AdapterError::SchemaParse. No network I/O.
- Re-exported from lib.rs.
- Tests: trait compiles for Ok and Err adapters; from_jsonschema bundle shape;
  placeholder handler errors; OperationAdapter import Ok + SchemaParse paths.
  All 178+N tests pass, clippy + fmt clean.

Unblocks alknet-http Phase 1 (from_openapi/from_mcp adapter implementations).

Refs: tasks/call/client/operation-adapter-trait.md, tasks/call/client/from-jsonschema.md
Refs: docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md §5
Refs: docs/architecture/crates/call/client-and-adapters.md
2026-06-26 12:56:28 +00:00
e4a25947d6 feat(call): remote_safe field on HandlerRegistration (ADR-028)
Adds the v1 data shape for peer-scoped default-deny registry filtering,
the one-way-door piece of the call-completion batch (ADR-028):

- HandlerRegistration gains pub remote_safe: bool, defaulting false across
  all provenance (Local, Session, FromOpenAPI, FromMCP, FromCall,
  FromJsonSchema) per ADR-028 §4. HandlerRegistration::new() keeps its
  existing 6-arg signature (defaults remote_safe: false), so all current
  call sites compile unchanged.
- Chainable HandlerRegistration::remote_safe(bool) setter + a
  OperationRegistryBuilder::remote_safe() helper that marks the
  most-recently-registered op (tracked via last_name, not HashMap
  iteration order which is unspecified).
- Field is data-only here — the filtering behavior (dispatch path +
  services/list hide) is wired in call/client/call-client, not this task.
  services/list is unchanged.
- Tests: default false, setter flips field, all six provenance variants
  default false, builder setter marks last op, existing call sites
  unchanged. 178 tests pass, clippy clean.

Refs: tasks/call/registry/remote-safe-marking.md
Refs: docs/architecture/decisions/028-callclient-peer-scoped-registry-filtering.md
2026-06-26 12:51:18 +00:00
2649e068e5 docs(arch): call-completion — ADR-028 peer-scoped filtering + client-and-adapters spec + tasks
Resolves the four gap-analysis decisions (DC-1..4) blocking the alknet-call
client/adapter surface specced in ADR-017:

- ADR-028 (new): locks the one-way door for DC-1 — CallClient registry is
  default-deny (remote_safe: bool on HandlerRegistration, default false across
  all provenance); share-global is an explicit trusted-peer opt-in; filtering
  is a dispatch-time read over the single Layer-0 registry, not a copy.
- client-and-adapters.md (new spec): operationally fills the gap ADR-017 left
  to implementation — CallClient, from_call, from_jsonschema, OperationAdapter
  trait, adapter location map, no-env-vars invariant, exchange-of-operations
  pattern. Keeps call-protocol.md and operation-registry.md under the
  700-line split threshold.
- ADR-017 amended: records DC-2/3/4 v1 defaults (auto-on-reconnect,
  error-on-collision, Result error type) and points DC-1 at ADR-028.
- OQ-25..28 (new): two-way-door remainders (remote_safe shape, AdapterError
  variants, re-import trigger, namespace collision) with v1 defaults recorded.
- Index/cross-ref updates across READMEs and the two existing call specs.

Tasks: 6 task files under tasks/call/ decomposing the completion work along
the gap-analysis priority order — remote-safe-marking (one-way door, first)
→ call-client (phase-risk) → from-call → operation-adapter-trait →
from-jsonschema (parallel with call-client) → review-completion. Graph
validated with taskgraph; parallelism designed in (from-jsonschema runs
concurrent with call-client/from-call once the trait lands).
2026-06-26 12:25:13 +00:00
6940d9858d docs(research): alknet-http phase-0 findings — HTTP server + client + MCP adapters
Phase 0 exploration for alknet-http (greenfield crate, no existing arch):
HTTP server (axum, ProtocolHandler for h2/http1.1, h3 deferred), HTTP client
(reqwest, the from_openapi/from_mcp forwarding handlers), MCP streamable HTTP
(feature-gated, stdio excluded as security position), to_openapi/to_mcp
projections.

Records: 8 design points (DH-3 HTTP→call operation mapping as the load-bearing
one), the settled adapter location map (from alknet-call gap analysis), the
no-env-vars invariant (Capabilities → from_openapi handler → HTTP header as the
credential injection point), and the prerequisite on alknet-call's
OperationAdapter trait being defined first.
2026-06-25 12:46:25 +00:00
79d8561bb4 docs(research): alknet-call completion gap analysis — CallClient + from_call + OperationAdapter
Gap analysis for completing alknet-call: the server-side core (~5.7k lines,
159 tests) is implemented, but the client side (CallClient), the bilateral
exchange mechanism (from_call), and the adapter contract (OperationAdapter
trait) are specced in ADR-017 and unimplemented.

Records: implementation state (verified against src/), 5 decisions needed
(peer-scoped registry filtering as the load-bearing one), the settled adapter
location map (trait + from_call + from_jsonschema in alknet-call; from_openapi/
from_mcp in alknet-http), the no-env-vars invariant (Capabilities → from_openapi
handler → HTTP header), and the exchange-of-operations runner pattern with
dispatch as the concrete downstream consumer.
2026-06-25 12:44:49 +00:00
db1dcd362f docs(research): revise alknet-ssh phase-0 — SOCKS5+forwarding in v1, TCP listener for git-over-ssh
Incorporates user clarifications: SOCKS5 and bidirectional port forwarding are
core non-negotiable v1 features (the VPN-like use case + the 3.5k-clones
demand). Adds DP-10 for the bare-TCP SSH listener as a first-class path needed
for future git-over-SSH, with config shape reserved in v1 (off-by-default,
default-deny). Grounds the client/forwarding recommendations in the dispatch
downstream consumer at /workspace/@alkdev/dispatch, which is a textbook russh
SSH client + direct-tcpip forwarder the user wants to replace with this stack.

alknet-ssh now owns both server and client + SOCKS5-server in v1; the SOCKS5
codec may extract to a separate crate later (two-way door).
2026-06-25 08:46:35 +00:00
d758a71490 docs(research): alknet-ssh phase-0 findings — stream wiring, russh 0.60.2, decision points
Phase 0 exploration for alknet-ssh: confirms SSH-over-QUIC-bistream via
tokio::io::join (no custom adapter needed, per reference impl), russh 0.60.2
generic run_stream/connect_stream, and channel-into-bistream multiplexing.

Surfaces 9 decision points for Phase 1: host key sourcing (vault-derived vs
config), channel policy v1 surface, client + SOCKS5 crate split, crypto
backend, auth method coverage, and a stream-handling POC to close russh's
upstream test gap.
2026-06-25 08:06:45 +00:00
011db05a52 test: implement coverage #005 Tier-A suggestions (S1-S4, S8)
Add 165 tests covering the directly-testable surface identified in
coverage review #005. Workspace coverage rises 87.1% -> 91.2%
(5759/6615 -> 6505/7135); all 389 tests pass, clippy clean.

- S1 (connection.rs): dispatch_envelope across all five event-type arms
  for Call + Subscribe, plus SubscriptionStream poll_next branches and
  SubscriptionStream::closed.
- S2 (types.rs): map_quinn/iroh_connection_error for TimedOut/Reset/
  ApplicationClosed/other, plus HandlerError + StreamError Debug/Display/
  source for every variant.
- S3 (config.rs): Ed25519SecretKey from_bytes/as_bytes round-trip,
  sign+verify, tampered-message rejection, Debug non-leakage.
- S4 (endpoint.rs): build_rustls_server_config RawKey/SelfSigned/Acme
  arms, build_quinn_server_config_from_rustls, load_private_key/
  load_cert_chain error paths, has_iroh_identity branches,
  AcceptAnyCertVerifier trait methods, Ed25519SigningKey trait impls
  (choose_scheme both branches, algorithm, public_key, sign, scheme),
  RawKeyCertResolver + AlknetEndpoint Debug. endpoint.rs 56% -> 73%.
- S8 (vault protocol.rs): the existing redacted-deserialize test passed
  for the wrong reason (JSON string failed Vec<u8> coercion before the
  guard). Two new tests exercise the guard directly via a [REDACTED] byte
  array (rejected) and a real payload (accepted). protocol.rs -> 100%.

Deferred to follow-up: S5 (loopback quinn integration test, the real
unlock for accept/dispatch/stream paths), S6 (ACME event-loop extraction),
S7 (adapter abort arm). Review #005 updated with the resolution.
2026-06-25 05:43:59 +00:00
32dcc05658 docs(reviews): add coverage analysis #005
First dedicated coverage pass (cargo-llvm-cov --workspace --all-features).
Workspace at 87.1% line coverage (5759/6615), all 224 tests pass. Vault
and registry layers are essentially fully covered; gaps concentrate in
endpoint.rs (56%), types.rs (57%), and connection.rs (54%), all stemming
from tests using MockConnection whose open_bi/accept_bi return Err.

Eight suggestions (S1-S8) ordered by leverage: pure-function tests for
dispatch_envelope / map_*_connection_error / error Display+Debug (S1-S3),
Tier A directly-callable TLS/rustls helpers in endpoint.rs (S4), one
loopback quinn integration test as the real unlock across four files (S5),
ACME event-loop extraction via synthetic stream (S6, the flagged research
item), and two small remaining gaps (S7-S8). No critical or warning
findings — this is a testing-infrastructure gap, not a logic gap.
2026-06-25 04:32:51 +00:00
00edfc0889 feat(core): ADR-027 — RawKey decoupling, client cert request, ACME integration
Three tasks implementing ADR-027:

1. core/rawkey-decouple-from-iroh: TlsIdentity::RawKey now uses
   Ed25519SecretKey (alknet-core-owned wrapper over ed25519_dalek)
   instead of iroh::SecretKey. RawKeyCertResolver and Ed25519SigningKey
   un-gated from #[cfg(all(quinn, iroh))] to #[cfg(quinn)] only.
   Quinn-only builds (default) now support RFC 7250 raw-key identity.
   iroh transport converts via iroh::SecretKey::from_bytes.

2. core/endpoint-request-client-cert: replaced with_no_client_auth()
   with AcceptAnyCertVerifier — a custom ClientCertVerifier that
   requests client certs but doesn't require them or verify against
   a CA. alknet's identity model is fingerprint-based (the
   authorized_fingerprints set is the trust anchor), not PKI-based.
   Peer certs are extracted at the TLS layer for fingerprinting;
   peers without certs connect normally.

3. core/acme-integration: TlsIdentity::Acme variant (domains,
   cache_dir, directory, contact) + AcmeDirectory enum. TlsSetup
   two-phase construction: synchronous for X509/RawKey/SelfSigned,
   async for Acme (spawns AcmeState event loop, builds ServerConfig
   with ResolvesServerCertAcme). acme-tls/1 ALPN added when ACME is
   active; dispatch_quinn guard closes challenge connections
   gracefully (challenge is TLS-layer-handled). acme feature gate
   keeps rustls-acme out of non-ACME builds.

Workspace: build/test/clippy green across all 3 feature configs
(quinn-only, quinn+iroh, quinn+acme, all-features). 331 tests, 0
failures, 0 warnings.
2026-06-24 20:29:43 +00:00
d94d7a132a docs(adr-027): TLS identity redesign — ACME + RawKey decoupling
ADR-027 resolves the architectural gap surfaced when ACME integration
became a concrete target:

1. TlsIdentity::Acme variant — static config data (domains, cache_dir,
   directory, contact) with async AcmeState constructed at endpoint
   setup via two-phase TlsSetup (not stuffed into the Clone-able enum).

2. TlsIdentity::RawKey decoupled from the iroh feature — uses
   Ed25519SecretKey (alknet-core-owned wrapper over ed25519_dalek)
   instead of iroh::SecretKey. Raw-key TLS identity (RFC 7250, the
   default for most alknet nodes) now works in quinn-only builds.
   iroh transport converts via SecretKey::from_bytes.

3. ACME feature-gated behind new acme feature (rustls-acme optional
   dep). Non-ACME builds don't compile it.

4. dispatch_quinn guard for acme-tls/1 challenge connections — TLS-ALPN-01
   is handled at the rustls cert resolver layer during the handshake;
   the guard closes challenge connections gracefully instead of logging
   a misleading "no handler" warning.

Research confirmed QUIC (quinn) handles ACME challenges differently than
TCP (reverse-proxy): quinn gives no ClientHello peek hook, but the
challenge is fully answered at the cert resolution step before the
connection surfaces to the application. No handler registration needed.

Spec updates: config.md, endpoint.md, open-questions.md (OQ-12),
overview.md + README.md (ADR index), ADR-010 (cross-ref).

Tasks: core/rawkey-decouple-from-iroh (gen 1, no deps),
core/acme-integration (gen 2, depends on rawkey). Graph: 36 tasks.
2026-06-24 12:29:24 +00:00
97216764ea fix: resolve review #004 findings W1-W4 + close review gate
W1 (call/protocol/abort-cascade-wiring): wire AbortCascade into
CallAdapter handle_stream for EVENT_ABORTED. Cascades with
AbortPolicy::AbortDependents, aborts root, no descendant frames on
wire (ADR-016 Decision 2). Two integration tests added.

W2 (core/endpoint-client-fingerprint): extract TLS client cert
fingerprint in dispatch_quinn (SHA256:<hex> of leaf cert DER via
peer_identity) and dispatch_iroh (ed25519:<hex> of peer NodeId).
Fingerprint format documented in auth.md. Server config change
(with_no_client_auth → request-but-don't-require) deferred to new
follow-up task core/endpoint-request-client-cert.

W3 (vault/mnemonic-debug-redaction): replace Mnemonic derive(Debug)
with manual redacting impl (phrase: "[REDACTED]"). Seed confirmed
no Debug impl. Redaction test added.

W4 (core/auth-apikey-resources): Option B — drop entry.resources from
spec. External identities (token/fingerprint) grant scopes only;
resource-scoped ACLs are composition-internal (ADR-015/022). auth.md
corrected + limitation documented. Two tests confirm empty resources.

review-post-impl-fixes: all 4 verified, workspace green (326 tests,
0 failures, 0 clippy warnings). Review #004 status → resolved.

Graph: 34 tasks, 12 gens.
2026-06-24 11:00:54 +00:00
d149932e2a tasks: decompose review #004 findings into 4 fix tasks + review gate
W1 (call/protocol/abort-cascade-wiring): wire AbortCascade into CallAdapter
handle_stream for EVENT_ABORTED. W2 (core/endpoint-client-fingerprint):
extract TLS client cert fingerprint in dispatch_quinn/dispatch_iroh.
W3 (vault/mnemonic-debug-redaction): replace Mnemonic derive(Debug) with
redacting impl. W4 (core/auth-apikey-resources, level: research): decide
whether ApiKeyEntry should carry resources, then implement or drop from
spec. review-post-impl-fixes gates on all four. Graph: 33 tasks, 12 gens.
2026-06-24 10:02:03 +00:00
d904dfc243 docs(reviews): add post-implementation sanity check #004
28-task implementation review across alknet-vault, alknet-core, alknet-call.
Zero critical findings; 4 warnings (W1: AbortCascade implemented but never
invoked by CallAdapter; W2: endpoint hard-codes tls_client_fingerprint=None;
W3: Mnemonic Debug leaks seed phrase; W4: ApiKeyEntry missing resources
field); 5 suggestions. Build clean, 332 tests pass, default clippy clean.
2026-06-23 22:13:20 +00:00
2c83e31e38 tasks: mark call/review-call completed — all 28 tasks done 2026-06-23 15:55:56 +00:00
4696c9a304 docs(call): record review-call pass — implementation conforms to spec (task: call/review-call)
Review of alknet-call crate against operation-registry.md, call-protocol.md,
and ADRs 005/012/014/015/016/017/022/023/024. All registry types, protocol types,
security constraints, and pattern consistency checks conformant. No source
deviations found. 159 tests pass; build/clippy/fmt/test clean.

Refs: docs/architecture/crates/call/README.md
2026-06-23 15:55:33 +00:00
23b76a240a docs(call): record review-call pass — implementation conforms to registry/protocol/ADR spec
call/review-call verified the alknet-call crate against operation-registry.md,
call-protocol.md, and ADRs 005/012/014/015/016/017/022/023/024. All
registry types, protocol types, security constraints (Capabilities
non-serializable/zeroized/immutable, metadata non-propagation, internal
ops -> NOT_FOUND, reachability bounds, UUID v4 request IDs), and pattern
consistency (OperationEnv trait, CompositeOperationEnv contains-probe,
authority switch, deadline inheritance) are conformant. 159 tests green;
build/clippy(fmt check) clean. No source changes required.
2026-06-23 15:55:10 +00:00
93589d4f52 tasks: mark call/protocol/abort-cascade completed 2026-06-23 15:50:38 +00:00
8aa384ccfa feat(call): implement abort cascade for nested calls (ADR-016) (task: call/protocol/abort-cascade)
Implement AbortCascade in protocol/abort.rs per ADR-016: PendingEntry stores
parent_request_id (Call & Subscribe) and a started flag for tree indexing.
AbortCascade::cascade_abort walks the call tree by parent_request_id and aborts
descendants per AbortPolicy (AbortDependents aborts all; ContinueRunning aborts
only unstarted via mark_started()). Returns sorted list of aborted IDs; unknown
root silently discarded. 20 unit tests covering depth-3 cascade, mixed
Call/Subscribe, determinism, both policies.

Refs: docs/architecture/crates/call/call-protocol.md
Implements: ADR-016
2026-06-23 15:50:05 +00:00
3317bc8d1a feat(call): implement abort cascade for nested calls (ADR-016) (task: call/protocol/abort-cascade)
- PendingEntry stores parent_request_id (Call and Subscribe) and started flag
  for abort-cascade tree indexing
- register_call/register_subscribe accept optional parent_request_id
- AbortCascade::cascade_abort walks the call tree by parent_request_id and
  aborts descendants per AbortPolicy (AbortDependents: all; ContinueRunning:
  unstarted only). Returns sorted list of aborted request IDs
- call.aborted for unknown request_id silently discarded (empty result)
- Composed child request_ids stay internal (not sent as call.requested)
- mark_started() tracks dispatch state for ContinueRunning decisions
- 20 unit tests covering AbortDependents/ContinueRunning, depth-3 tree,
  unknown root, mixed Call/Subscribe, determinism
2026-06-23 15:49:07 +00:00
bea19de3cf tasks: mark call/protocol/call-adapter completed 2026-06-23 15:39:54 +00:00
2ff09a728c feat(call): implement CallAdapter — ProtocolHandler for alknet/call (task: call/protocol/call-adapter)
Implement CallAdapter in protocol/adapter.rs: ProtocolHandler for ALPN
alknet/call with stream handling, per-request identity resolution (auth_token
overrides connection identity, falls back on failure), root context construction
(internal:false, deadline, capabilities+scoped_env from registration bundle),
env composition (CompositeOperationEnv with Layer 0 base + Layer 2 connection
overlay + optional Layer 1 session overlay), operationId leading slash stripped,
ResponseEnvelope→EventEnvelope conversion, PendingRequestMap sweeper, connection
drop fails all pending. SessionOverlaySource trait. 22 unit tests.

Refs: docs/architecture/crates/call/call-protocol.md
Implements: ADR-002, ADR-012, ADR-014, ADR-015, ADR-017, ADR-022, ADR-024
2026-06-23 15:39:28 +00:00
fc9f93e893 feat(call): implement CallAdapter (ProtocolHandler for alknet/call) with stream handling, identity resolution, root context construction (task: call/protocol/call-adapter)
- CallAdapter struct with registry, identity_provider, session_source, default_timeout (30s)
- new(), with_session_source(), with_timeout() constructors
- SessionOverlaySource trait defined (overlay_for) for agent-crate integration
- ProtocolHandler::alpn() returns b"alknet/call"
- handle() sets connection identity from AuthContext, spawns accept_bi loop,
  reads EventEnvelope frames via FrameFramedReader, dispatches call.requested
  to the operation registry, writes ResponseEnvelope as EventEnvelope via
  FrameFramedWriter
- Per-request identity resolution: AuthContext.identity used by default,
  auth_token in payload overrides via IdentityProvider::resolve_from_token();
  resolution failure falls back to connection-level identity
- build_root_context sets internal: false, deadline (now + default_timeout),
  capabilities and scoped_env from registration bundle, parent_request_id: None
- compose_root_env builds CompositeOperationEnv (Layer 0 curated base +
  Layer 2 connection overlay + optional Layer 1 session overlay)
- operationId leading slash stripped before registry lookup
- ResponseEnvelope -> EventEnvelope conversion (Ok -> call.responded,
  Err -> call.error)
- PendingRequestMap sweeper runs every 10s, evicts expired wire entries
- Connection drop: fail_all pending with INTERNAL "connection closed",
  return Ok(())
- Stream reset: FrameFramedReader error closes stream; other streams unaffected
- Handler panic: stream task isolated via tokio::spawn, sweep cleans entry
- Tests: alpn, constructors, slash strip, identity resolution (override/fallback),
  root context (internal=false, deadline, capabilities, scoped_env), env
  composition (layers aggregate, session overlay), dispatch round-trip,
  internal op from wire -> NOT_FOUND, ACL denied -> FORBIDDEN, auth_token
  overrides connection identity, unknown op -> NOT_FOUND, no-slash resolution,
  ResponseEnvelope -> EventEnvelope conversions
2026-06-23 15:38:50 +00:00
0d0f0f8da6 tasks: mark core/review-core completed 2026-06-23 15:33:16 +00:00
061069910b fix(core): gate RawKey TLS helpers on quinn+iroh for clean iroh-only builds (task: core/review-core)
Review of alknet-core found one issue: RawKeyCertResolver/Ed25519SigningKey/
std::path::Path were gated on #[cfg(feature = "iroh")] but only used in the
quinn TLS path — caused clippy -D warnings failures on iroh-only builds.
Re-gated to #[cfg(all(feature = "quinn", feature = "iroh"))]. All 4 feature
combinations now pass clippy -D warnings; 55 tests pass.

Review confirmed: all core types, config, auth, and endpoint implementations
are spec-conformant.
2026-06-23 15:32:23 +00:00
4774364c72 fix(core): gate RawKey TLS helpers on quinn+iroh for clean iroh-only builds (task: core/review-core)
The RawKeyCertResolver, Ed25519SigningKey, and std::path::Path imports
were gated on #[cfg(feature = "iroh")] but are only used in the quinn
TLS server-config path (build_rustls_server_config RawKey arm). With
iroh-only builds (--no-default-features --features iroh), these became
dead code and triggered clippy -D warnings failures.

Re-gated to #[cfg(all(feature = "quinn", feature = "iroh"))] so they
only compile when both features are active (the combination that
actually uses raw-key TLS via quinn). std::path::Path is now
#[cfg(feature = "quinn")] since it is only used by quinn's
load_cert_chain/load_private_key helpers.

Verified: cargo clippy passes with -D warnings across all four feature
combinations (none, quinn, iroh, quinn+iroh). cargo test --all-features
passes 55 tests. cargo fmt --check clean.
2026-06-23 15:31:42 +00:00
60556bbe0c tasks: mark core/endpoint and call/protocol/call-connection completed 2026-06-23 15:18:56 +00:00
c68050ae0f feat(call): implement CallConnection with imported-ops overlay and call/subscribe/abort (task: call/protocol/call-connection)
Implement CallConnection in protocol/connection.rs with Layer 2 imported-ops
overlay (Arc<RwLock<HashMap>>), register_imported/register_imported_all,
overlay_env() returning an OperationEnv that dispatches to imported ops,
and call()/subscribe()/abort() methods that open a stream, send call.requested,
register in PendingRequestMap, spawn a stream reader, and correlate responses
by ID. Connection drop drops the overlay. Exposed MockConnection +
Connection::from_mock in alknet-core for cross-crate testing. 9 new connection
tests (102 total in alknet-call).

Refs: docs/architecture/crates/call/call-protocol.md
Implements: ADR-012, ADR-017, ADR-024
2026-06-23 15:17:55 +00:00
7b92749acd tasks: mark core/endpoint completed 2026-06-23 15:16:33 +00:00
ddc6c07fea feat(call): implement CallConnection with imported-ops overlay (Layer 2) and call/subscribe/abort methods
Implements CallConnection in src/protocol/connection.rs representing an
established alknet/call connection (either direction). Holds the Layer 2
imported-ops overlay (ADR-024) as Arc<RwLock<HashMap>>.

- register_imported / register_imported_all add to the connection overlay
- overlay_env returns an OperationEnv dispatching to imported ops; contains()
  returns true only for ops in the overlay
- call() opens a stream, sends call.requested, registers in PendingRequestMap,
  spawns a stream reader, resolves on first call.responded
- subscribe() sends call.requested and yields call.responded until
  call.completed/call.aborted via a SubscriptionStream wrapping the mpsc receiver
- abort() sends call.aborted for the request ID and removes the pending entry
- connection drop drops the overlay (no explicit deregistration needed)

Exposes MockConnection trait and Connection::from_mock in alknet-core so
cross-crate tests can construct mock connections without real QUIC. Removes
two unused test helpers in env.rs that triggered dead-code warnings under
-D warnings. Adds parking_lot dep for the overlay RwLock and pending Mutex.

9 new connection tests (102 total in alknet-call). Clippy clean.
2026-06-23 15:16:10 +00:00
79bc6ffb31 feat(core): implement AlknetEndpoint, HandlerRegistry, accept loops, TLS identity, graceful shutdown (task: core/endpoint)
Implement the ALPN router and endpoint in endpoint.rs: AlknetEndpoint with
quinn+iroh accept loops (both feature-gated, both Option), HandlerRegistry
(new/register/get/alpn_strings with panic-on-duplicate), dispatch via
tokio::spawn by ALPN, AuthContext construction from connection
(alpn/remote_addr/fingerprint/identity), TLS identity modes (RawKey RFC 7250
via on-the-fly cert resolver, X509 from files, SelfSigned via rcgen),
EndpointError enum, graceful shutdown with drain timeout + force close.
ACME deferred as TODO per task spec. 55 tests (--all-features), 52 (default),
47 (no-default); clippy clean across all 3 feature combos.

Refs: docs/architecture/crates/core/endpoint.md
Implements: ADR-010
2026-06-23 15:14:26 +00:00
8d056a2b59 feat(core): implement AlknetEndpoint, HandlerRegistry, accept loops (quinn + iroh), TLS identity (RawKey/X509/SelfSigned), and graceful shutdown (task: core/endpoint) 2026-06-23 15:12:14 +00:00
3484373d84 tasks: mark call/registry/service-discovery completed 2026-06-23 14:55:42 +00:00
8cc16de9f0 feat(call): implement services/list and services/schema built-in operations (task: call/registry/service-discovery)
Implement services/list and services/schema in registry/discovery.rs: spec
constructors, factory handlers taking Arc<OperationRegistry>, JSON serialization
of OperationSpec (incl. error_schemas per ADR-023), leading-slash normalization
for services/schema, NOT_FOUND for unknown ops, INVALID_INPUT for missing name.
Both registered as Local provenance with empty authority/env/caps and empty
AccessControl.

Refs: docs/architecture/crates/call/operation-registry.md
Implements: ADR-023
2026-06-23 14:55:09 +00:00
bb4e32e849 feat(call): implement services/list and services/schema built-in operations (task: call/registry/service-discovery) 2026-06-23 14:54:17 +00:00
4f10af2295 tasks: mark call/registry/operation-env completed 2026-06-23 14:53:42 +00:00
99ef22db3e feat(call): implement LocalOperationEnv and CompositeOperationEnv (task: call/registry/operation-env)
Implement LocalOperationEnv (Layer 0 — Arc<OperationRegistry>, reachability check,
authority switch, fresh metadata, inherited deadline/env) and CompositeOperationEnv
(session → connection → base overlay dispatch via contains() probe per ADR-024).
ScopedOperationEnv checked before overlay dispatch. 13 new env tests (93 total).

Refs: docs/architecture/crates/call/operation-registry.md
Implements: ADR-024
2026-06-23 14:53:05 +00:00
7e824af022 feat(call): implement LocalOperationEnv and CompositeOperationEnv (task: call/registry/operation-env)
Expand the minimal OperationEnv trait from the operation-context task with
concrete dispatch implementations per ADR-024:

- LocalOperationEnv (Layer 0): wraps Arc<OperationRegistry>. invoke_with_policy
  runs the scoped_env reachability check (ADR-015/022), looks up the
  registration, then constructs a child OperationContext with internal: true,
  identity = parent.handler_identity.as_identity() (the ADR-015 authority
  switch), fresh metadata (HashMap::new() — ADR-014 security constraint, no
  parent metadata propagation), inherited deadline (parent.deadline, not a
  fresh 30s), inherited env (parent.env.clone() — Arc::clone per ADR-024), and
  the child's own composition_authority + scoped_env from its registration.
  contains() uses the default impl (returns true — curated registry contains
  everything it can dispatch).

- CompositeOperationEnv (per-call, ADR-024): composes session (Layer 1),
  connection (Layer 2), and base (Layer 0) trait objects. invoke_with_policy
  runs the same reachability check, then probes overlays in order via
  contains() (the overlay-dispatch contract from review #003 C9), dispatching
  to the first overlay that contains the op. contains() aggregates all layers.

The trait-object design is load-bearing: making OperationEnv concrete would
close the session-overlay and connection-overlay patterns. Same integration-
point pattern as IdentityProvider (ADR-004).

Tests cover: allowed/disallowed reachability, internal-flag propagation,
authority switch (child identity = parent handler_identity), fresh metadata,
inherited deadline, composite session-overlay dispatch, composite fall-through
to base, composite connection-overlay dispatch when session lacks op, and
composite contains aggregation.
2026-06-23 14:51:48 +00:00
31fd8a73ac tasks: mark call/registry/handler-registration completed 2026-06-23 14:42:01 +00:00
3aae9d1323 feat(call): implement handler registration and operation registry (task: call/registry/handler-registration)
Implement Handler, HandlerRegistration, OperationProvenance, OperationRegistry
(register/registration/invoke/list_operations), and OperationRegistryBuilder
(new/with_local/with_leaf/with_leaf_provenance/with/build) in registry/registration.rs.
invoke enforces visibility (Internal from wire → NOT_FOUND), ACL with authority
switch (internal: true → handler_identity.as_identity(), internal: false → caller
identity), and handler dispatch. 21 unit tests.

Refs: docs/architecture/crates/call/operation-registry.md
Implements: ADR-015, ADR-022, ADR-024
2026-06-23 14:40:50 +00:00
7345ef5442 Implement handler registration and operation registry
Implements the dispatch core for the call protocol per ADR-022 and ADR-024:

- Handler async closure type alias returning ResponseEnvelope
- HandlerRegistration bundle: spec, handler, provenance, composition
  authority, scoped env, capabilities
- OperationProvenance enum with all 6 variants (Local, FromOpenAPI,
  FromMCP, FromCall, FromJsonSchema, Session)
- OperationRegistry with register/registration/invoke/list_operations
- invoke flow: visibility check (Internal from wire -> NOT_FOUND),
  ACL with authority switch (internal: true -> handler_identity,
  internal: false -> caller identity), handler dispatch
- OperationRegistryBuilder with new/with_local/with_leaf/with_leaf_provenance/with/build
- make_handler helper for boxing async handlers
- 21 unit tests covering invoke, visibility, ACL authority switch,
  builder provenance, and lookup behavior
2026-06-23 14:40:13 +00:00
c2c88833db tasks: mark call/registry/operation-context completed 2026-06-23 14:30:37 +00:00
fbc30d281e feat(call): implement OperationContext, AbortPolicy, CompositionAuthority, ScopedOperationEnv (task: call/registry/operation-context)
Implement operation context types in registry/context.rs: OperationContext (10
fields, internal pub(crate) + is_internal()), AbortPolicy (AbortDependents
default), CompositionAuthority (none/new/as_identity for ACL), ScopedOperationEnv
(empty/new/allows), generate_request_id (UUID v4). Added minimal OperationEnv
trait in registry/env.rs (invoke/invoke_with_policy/contains) so the env field
compiles — operation-env task will expand with LocalOperationEnv and
CompositeOperationEnv. 37 unit tests.

Refs: docs/architecture/crates/call/operation-registry.md
Implements: ADR-015, ADR-022, ADR-024
2026-06-23 14:29:18 +00:00
3b9c480dad Implement OperationContext, AbortPolicy, CompositionAuthority, ScopedOperationEnv
Implements the operation context types in registry/context.rs (ADR-015,
ADR-022, ADR-024): OperationContext with all 10 fields (internal is
pub(crate) for writes, read via is_internal()), AbortPolicy enum with
AbortDependents default, CompositionAuthority with synthetic Identity
projection for ACL, ScopedOperationEnv reachability set, and
generate_request_id() (UUID v4). Adds a minimal OperationEnv trait
forward-declaration in registry/env.rs so the context env field compiles;
the operation-env task will expand it.
2026-06-23 14:27:46 +00:00
de91f3bdb0 tasks: mark call/protocol/pending-request-map completed 2026-06-23 14:25:58 +00:00
f92e7af13f feat(call): implement PendingRequestMap for call correlation (task: call/protocol/pending-request-map)
Implement PendingRequestMap in protocol/pending.rs with Call (oneshot) and
Subscribe (mpsc) entries, ID-based correlation (ADR-012), timeout-based eviction,
fail_all for connection close, and silent discard of unknown request IDs. 17 unit
tests.

Refs: docs/architecture/crates/call/call-protocol.md
Implements: ADR-012
2026-06-23 14:24:44 +00:00
e63a36ede0 Implement PendingRequestMap for call protocol
Correlates call.responded events back to call.requested by request ID
(stream-agnostic per ADR-012). Manages Call (oneshot) and Subscribe
(mpsc) entries with timeout-based eviction and fail_all on connection
close. Unknown request IDs are silently discarded.
2026-06-23 14:23:49 +00:00
dabb0d8b68 tasks: mark vault/spec-sync-remove-drift completed 2026-06-23 14:17:39 +00:00
d0f633c71d docs(vault): remove drift tracking artifacts, bump vault docs to stable (task: vault/spec-sync-remove-drift)
Remove the Known Source Drift table from vault/README.md. Remove all 'known
drift'/'current source uses X' prose from Security Constraints in README,
encryption.md, service.md (constraint statements preserved). Remove stale
ADR-025/postcard notes in protocol.md. Bump all 5 vault doc frontmatter to
status: stable. Update architecture/README.md vault doc statuses to stable
and Current State to remove 'pending ADR-025/026 refactor' language.

Refs: docs/architecture/crates/vault/README.md (drift cleanup)
2026-06-23 14:17:10 +00:00
482901db74 tasks: mark core/config completed 2026-06-23 14:16:38 +00:00
323ee85d40 docs(vault): remove drift tracking artifacts, bump vault docs to stable
The vault spec-to-implementation sync is complete. Remove the drift
tracking tools that were only needed during sync:

- Remove the Known Source Drift table from vault/README.md
- Remove 'known drift' / 'current source uses X' prose from Security
  Constraints sections in vault/README.md, encryption.md, and service.md.
  The permanent constraint statements (OsRng for IVs, zeroized drop,
  no unwrap, etc.) are preserved.
- Remove the drift paragraph in encryption.md Key Versioning.
- Remove stale 'to be updated per ADR-025' / 'postcard tests to be
  removed' notes in protocol.md References.
- Bump status: draft -> stable in the frontmatter of all vault docs
  (README, mnemonic-derivation, encryption, service, protocol).
- Update architecture/README.md: vault doc status entries to stable,
  Current State paragraph reflects vault implementation complete (no
  'pending ADR-025/026 refactor' language).
2026-06-23 14:15:13 +00:00
da5646bf46 feat(core): implement StaticConfig, DynamicConfig, AuthPolicy, ApiKeyEntry, RateLimitConfig, ConfigReloadHandle, TlsIdentity, ConfigError (task: core/config)
Implement all configuration types in config.rs: StaticConfig (drain_timeout=2s
default), TlsIdentity (X509/RawKey[iroh-gated]/SelfSigned), DynamicConfig
(Clone/Debug/Default, ArcSwap-reloadable), AuthPolicy (String fingerprints, no
russh), ApiKeyEntry (5 fields), RateLimitConfig (100/5 defaults),
ConfigReloadHandle (reload/dynamic via ArcSwap), ConfigError (thiserror, all
variants). iroh_relay and RawKey feature-gated to iroh. 14 unit tests.

Refs: docs/architecture/crates/core/config.md
Implements: ADR-003, ADR-010

# Conflicts:
#	crates/alknet-core/src/config.rs
2026-06-23 14:14:51 +00:00
e98cfa77d8 Implement core/config: StaticConfig, DynamicConfig, AuthPolicy, ApiKeyEntry, RateLimitConfig, ConfigReloadHandle, TlsIdentity, ConfigError
- StaticConfig: immutable startup config (listen_addr, tls_identity, iroh_relay, drain_timeout=2s)
- TlsIdentity enum: X509, RawKey (iroh feature-gated), SelfSigned
- DynamicConfig: hot-reloadable via ArcSwap (auth + rate_limits), derives Clone/Debug/Default
- AuthPolicy: authorized_fingerprints (HashSet<String>), api_keys (Vec<ApiKeyEntry>) — no russh dep
- ApiKeyEntry: prefix/hash/scopes/description/expires_at
- RateLimitConfig: max_connections_per_ip=100, max_auth_attempts=5
- ConfigReloadHandle: reload() atomic swap, dynamic() load_full
- ConfigError: thiserror enum with all variants

14 unit tests covering defaults, construction, atomic reload swap, and error displays.
2026-06-23 14:11:07 +00:00
b93a85a280 tasks: mark vault/review-vault-sync and core/auth completed 2026-06-23 14:10:54 +00:00
a4b4d89d8f feat(core): implement AuthContext, Identity, AuthToken, IdentityProvider, ConfigIdentityProvider (task: core/auth)
Implement authentication types in auth.rs: AuthContext (Clone, 4 fields),
Identity (Clone, PartialEq), AuthToken, IdentityProvider trait (resolve_from_
fingerprint + resolve_from_token), ConfigIdentityProvider (reads from
ArcSwap<DynamicConfig> on every call — hot-reloadable). Fingerprint resolution
via authorized_fingerprints HashSet, token resolution via alk_ prefix + SHA-256
hash + expiry check. Also implemented minimal config.rs types (DynamicConfig,
AuthPolicy, ApiKeyEntry, RateLimitConfig, ConfigReloadHandle) needed by auth —
aligned with architecture docs for the parallel core/config task to extend.

27 unit tests pass; clippy clean.

Refs: docs/architecture/crates/core/auth.md
Implements: ADR-004, ADR-011
2026-06-23 14:10:06 +00:00
d7d879a3fa vault: spec-conformance fixes from review (task: vault/review-vault-sync)
Review of vault crate against all architecture specs. Fixed 5 deviations:
1. EncryptionKey: removed Clone (now move-only per spec), added redacting Debug
2. EncryptionKey::new made private (cfg(test)), added pub(crate) key_bytes()
3. encrypt/decrypt made pub(crate) per encryption.md, low-level crypto tests
   moved from integration to unit tests
4. CachedKey refactored to wrap DerivedKey with cached_at/last_accessed fields
   per service.md, with key_type()/private_key()/public_key() accessors
5. Mnemonic::to_seed() unwrap() eliminated by storing validated Bip39Mnemonic
   (enabled bip39 zeroize feature for proper zeroization)

All 10 drift items verified resolved. 105 tests pass; clippy clean.

Refs: docs/architecture/crates/vault/README.md (review checklist)
2026-06-23 14:09:36 +00:00
20b5c640ec tasks: mark call/protocol/wire-types completed 2026-06-23 14:08:50 +00:00
8dc842b1f4 feat(core): implement AuthContext, Identity, AuthToken, IdentityProvider, ConfigIdentityProvider (task: core/auth)
- auth.rs: Identity, AuthContext, AuthToken, IdentityProvider trait, ConfigIdentityProvider
- ConfigIdentityProvider reads from ArcSwap<DynamicConfig> on every call (hot-reloadable)
- Fingerprint resolution via authorized_fingerprints; token resolution via alk_ prefix + SHA-256 hash + expiry check
- config.rs: minimal DynamicConfig, AuthPolicy (with resolve methods), ApiKeyEntry, RateLimitConfig, ConfigReloadHandle required by auth
- Unit tests: fingerprint resolution (known/unknown/empty), token resolution (valid/expired/unknown/wrong-hash/non-alk), config reload changes results immediately
- Add sha2, hex deps to alknet-core
2026-06-23 14:08:33 +00:00
55404e52a3 feat(call): implement wire protocol types and framing (task: call/protocol/wire-types)
Implement EventEnvelope, ResponseEnvelope, CallError, FrameError, and
FrameFramedReader/FrameFramedWriter with 4-byte big-endian length-prefixed JSON
framing in protocol/wire.rs. Added ResponseEnvelope helpers (ok/error/not_found/
forbidden) and ResponseEnvelope→EventEnvelope conversion. 20 unit tests.

Refs: docs/architecture/crates/call/call-protocol.md
Implements: ADR-005, ADR-012, ADR-023
2026-06-23 14:08:03 +00:00
41f0fc7843 vault: spec-conformance fixes from review (task: vault/review-vault-sync)
- EncryptionKey: remove Clone (move-only per spec), add custom redacting
  Debug impl, make new() private (cfg(test)), add pub(crate) key_bytes()
  accessor, make encrypt/decrypt pub(crate) module-internal helpers
- CachedKey: refactor to wrap DerivedKey (per service.md) with cached_at
  and last_accessed fields; add key_type()/private_key()/public_key()
  accessors
- Mnemonic: store validated Bip39Mnemonic to eliminate unwrap() in
  to_seed(); enable bip39 zeroize feature so inner is zeroized on drop
- Fix clippy: remove unused import in drop_tracker tests, use struct
  init syntax instead of field reassignment with Default
- Move low-level EncryptionKey round-trip/wrong-key tests from
  integration tests to unit tests (encrypt/decrypt now pub(crate))
2026-06-23 14:07:24 +00:00
c9898566b9 Implement call protocol wire types and framing
Implements src/protocol/wire.rs with:
- EventEnvelope (type/id/payload, JSON wire format with leading-slash op ids)
- ResponseEnvelope and CallError (with optional typed details, ADR-023)
- ResponseEnvelope::ok/error/not_found/forbidden helpers
- ResponseEnvelope -> EventEnvelope conversion (Ok -> call.responded, Err -> call.error)
- FrameFramedReader / FrameFramedWriter: 4-byte big-endian length-prefixed JSON frames
- FrameError: Io, Json, ConnectionClosed, InvalidFrame
- 20 unit tests covering round-trip, large payloads, truncated frames, helpers

Builds on the call/crate-init skeleton. See
docs/architecture/crates/call/call-protocol.md and ADR-005/012/023.
2026-06-23 14:06:48 +00:00
e0ccdc28ac tasks: mark call/registry/operation-spec completed 2026-06-23 14:06:26 +00:00
6d536a3bf5 feat(call): implement OperationSpec, AccessControl, Visibility, ErrorDefinition (task: call/registry/operation-spec)
Implement operation specification types in registry/spec.rs: OperationSpec (with
path() returning /{name}, namespace derived from name), OperationType (Query,
Mutation, Subscription), Visibility (External, Internal), ErrorDefinition (ADR-023),
AccessControl::check returning AccessResult (AND/OR scope checks, resource checks,
None identity → 'authentication required', empty ACL → Allowed). 9 unit tests.

Refs: docs/architecture/crates/call/operation-registry.md
Implements: ADR-015, ADR-023
2026-06-23 14:04:42 +00:00
b46fc81dc5 Implement OperationSpec, AccessControl, Visibility, ErrorDefinition 2026-06-23 14:03:27 +00:00
669feab741 tasks: mark core/core-types completed 2026-06-23 13:54:28 +00:00
96938092ca feat(core): implement core types — ProtocolHandler, Connection, Capabilities (task: core/core-types)
Implement all core types in types.rs: ProtocolHandler trait (alpn + handle),
HandlerError (4 variants), Connection (quinn/iroh feature-gated enum dispatch,
OnceLock write-once identity, accept_bi/open_bi/close/remote_alpn/remote_addr),
BiStream trait, SendStream/RecvStream AsyncWrite/AsyncRead wrappers, StreamError,
From<StreamError> for HandlerError, Capabilities (Zeroize+ZeroizeOnDrop, immutable
builder API, Secret<String> wrapper, non-serializable), IdentityAlreadySet. Added
minimal Identity/AuthContext in auth.rs as foundation for the auth task.

Notable: quinn::Connection has no alpn() accessor so ALPN is stored separately
(from_quinn_with_alpn); iroh 0.35 types accessed via iroh::endpoint::*; iroh
Connection has no remote_address (returns None per spec).

Refs: docs/architecture/crates/core/core-types.md
Implements: ADR-002, ADR-007, ADR-014, ADR-022
2026-06-23 13:53:47 +00:00
8611935f1a feat(core): implement core types (ProtocolHandler, Connection, Capabilities)
Implements the foundational types in alknet-core/src/types.rs per the
core-types architecture (ADRs 002, 007, 014, 022):

- ProtocolHandler trait (alpn + async handle) with HandlerError
- Connection wrapping quinn/iroh via internal enum dispatch (feature-gated);
  write-once identity via OnceLock, IdentityAlreadySet on second set
- SendStream/RecvStream concrete wrappers implementing AsyncWrite/AsyncRead
- BiStream convenience trait
- StreamError with canonical From<StreamError> for HandlerError
- Capabilities: non-serializable, Zeroize + ZeroizeOnDrop, immutable builder
  API (new/with_api_key/with_http_token/get) backed by a Secret<String> wrapper
- Minimal Identity and AuthContext in auth.rs as the foundation the auth task
  extends

13 unit tests cover Capabilities (build/get/clone/zeroize/redaction) and
Connection::set_identity (once succeeds, twice errors). Verified across
feature combos (default, no-default, iroh-only): build, clippy -D warnings,
test, fmt --check all clean.

(task: core/core-types)
2026-06-23 13:49:12 +00:00
016c30691d tasks: mark call/crate-init completed 2026-06-23 13:47:49 +00:00
51f80e90bb feat(call): initialize alknet-call crate skeleton (task: call/crate-init)
Create crates/alknet-call with Cargo.toml (deps: alknet-core path, irpc workspace,
tokio, serde, serde_json, async-trait, tracing, thiserror, uuid, futures), src/lib.rs,
and module skeletons for registry (spec, context, registration, env, discovery) and
protocol (wire, pending, connection, adapter, abort). Added to workspace members.

Refs: docs/architecture/crates/call/README.md
Implements: ADR-003, ADR-005
2026-06-23 13:46:11 +00:00
e13a150d9f feat(call): initialize alknet-call crate skeleton (task: call/crate-init)
Create crates/alknet-call with Cargo.toml, lib.rs, and module skeletons
for the registry (spec, context, registration, env, discovery) and
protocol (wire, pending, connection, adapter, abort) subsystems. Add the
crate to the workspace members list. Depends on alknet-core (workspace
path), irpc (workspace dep), tokio, serde, serde_json, async-trait,
tracing, thiserror, uuid, and futures. Implements ProtocolHandler on
ALPN alknet/call per docs/architecture/crates/call.
2026-06-23 13:45:14 +00:00
968e3a09ee tasks: mark vault/key-versioning-rotation completed 2026-06-23 13:39:37 +00:00
9eab93100e vault: version-indexed encryption key paths, bump CURRENT_KEY_VERSION to 2, add rotate (task: vault/key-versioning-rotation)
Drift items #3, #9, #10: implement the version-indexed key rotation mechanism
(ADR-021). Bump CURRENT_KEY_VERSION to 2 (HD-derived per ADR-020). Add
encryption_path_for_version in derivation.rs, derive_encryption_key_for_version
+ version-aware encrypt/decrypt + rotate method on VaultServiceHandle. Each
version maps to a distinct derivation path; the blob carries its own version.

Refs: docs/architecture/crates/vault/README.md drift #3, #9, #10
Implements: ADR-020, ADR-021

# Conflicts:
#	crates/alknet-vault/src/derivation.rs
#	crates/alknet-vault/src/service.rs
2026-06-23 13:39:05 +00:00
25327b41d4 tasks: mark vault/remove-password-derivation, vault/unlock-new-zeroizing-return, vault/poisoned-lock-recovery completed 2026-06-23 13:36:49 +00:00
bc8e329f90 vault: replace unwrap() on RwLock with poisoned-lock recovery (task: vault/poisoned-lock-recovery)
Drift item #2: replace all .read().unwrap()/.write().unwrap() calls in
VaultServiceHandle with .unwrap_or_else(|e| e.into_inner()) to recover from
poisoned locks instead of bricking the vault. Added test_poisoned_lock_recovery
that poisons the lock via a panicking thread and verifies the vault remains
usable.

Refs: docs/architecture/crates/vault/README.md drift #2
Implements: ADR-025

# Conflicts:
#	crates/alknet-vault/src/service.rs
2026-06-23 13:35:53 +00:00
55d356cb4e feat(vault): version-indexed encryption key paths, CURRENT_KEY_VERSION=2, rotate method (ADR-021)
- Bump CURRENT_KEY_VERSION from 1 to 2 (v1 reserved for TS PBKDF2 legacy per ADR-020)
- Add derivation::encryption_path_for_version(version) -> m/74'/2'/0'/{version-2}', returns InvalidPath for version < 2
- Add VaultServiceHandle::derive_encryption_key_for_version(version), cached by path, returns InvalidPath for version < 2
- encrypt/decrypt now derive at encryption_path_for_version(key_version) instead of fixed PATHS::ENCRYPTION
- Add VaultServiceHandle::rotate(encrypted, to_version): decrypt old, re-encrypt new
- Update existing tests to use v2; add round-trip, rotation, partial-rotation, and invalid-version tests

Task: vault/key-versioning-rotation
2026-06-23 13:35:44 +00:00
ad1174b485 vault: change unlock_new return type to Zeroizing<String> (task: vault/unlock-new-zeroizing-return)
Drift item #8: the mnemonic phrase is the root of trust — it must not linger in
freed heap memory. Changed unlock_new return from String to Zeroizing<String>
(zeroized on drop). Existing tests work via Deref coercion.

Refs: docs/architecture/crates/vault/README.md drift #8
Implements: ADR-025 (resolves W7)
2026-06-23 13:33:55 +00:00
aec4bc9b87 refactor(vault): remove derive_password and site_password_path (task: vault/remove-password-derivation)
Drift item #7: remove the password-manager pattern (derive_password,
derive_password_string, site_password_path) — not relevant to an RPC system's
vault. Removed methods, path function, doc-table row, all tests, and the
now-unused base64 URL_SAFE_NO_PAD import.

Refs: docs/architecture/crates/vault/README.md drift #7
Implements: ADR-025 (resolves C9)
2026-06-23 13:33:36 +00:00
9045dd83d3 vault: replace RwLock unwrap with poisoned-lock recovery
Replace all .read().unwrap() and .write().unwrap() calls in
VaultServiceHandle methods with .unwrap_or_else(|e| e.into_inner())
so a panic while holding the lock does not brick the vault for all
subsequent operations. Add unit test that poisons the lock and
verifies the next call recovers.
2026-06-23 13:33:00 +00:00
685413dee4 vault: return Zeroizing<String> from unlock_new
Change unlock_new return type from String to Zeroizing<String>
so the generated mnemonic phrase is zeroized on drop and does not
linger in freed heap memory. Resolves drift item #8 / review W7.
2026-06-23 13:33:00 +00:00
06b715322a refactor(vault): remove derive_password and site_password_path (ADR-025)
Drop the password-manager pattern from alknet-vault (drift item #7,
ADR-025, resolves review #002 C9). Site-specific password derivation
is not relevant to an RPC system's vault.

Removed:
- derive_password method from VaultServiceHandle (service.rs)
- derive_password_string method from VaultServiceHandle (service.rs)
- site_password_path function from derivation.rs
- site-password derivation path row from derivation.rs doc table
- All password-derivation tests from service.rs and derivation.rs
- Now-unused base64 URL_SAFE_NO_PAD import from service.rs
2026-06-23 13:32:45 +00:00
1ac5585f84 tasks: mark vault/derivedkey-serialization completed 2026-06-23 13:32:35 +00:00
68d2068f36 vault: always-redact DerivedKey serialization, reject redacted payloads on deserialize (task: vault/derivedkey-serialization)
Drift item #5: replace DerivedKey's dual serialization behavior (JSON redacts,
binary preserves) with always-redact. Custom Serialize always redacts private_key
as "[REDACTED]"; custom Deserialize rejects "[REDACTED]" payloads with an
explicit error. Dropped the is_human_readable() branch that preserved bytes in
binary formats (postcard path removed by ADR-025). Debug impl already redacted.

Refs: docs/architecture/crates/vault/README.md drift #5
Implements: ADR-025 (resolves W8)
2026-06-23 13:31:19 +00:00
bd4c2bc268 vault: always-redact DerivedKey serialization, reject redacted payloads on deserialize
Replace derived Deserialize with a custom impl that rejects
private_key == b"[REDACTED]" with an explicit error, and make the
custom Serialize impl always redact (drop the human-readable-only
branch). Updates the redaction-rejection and debug-no-leak tests.

Resolves drift item #5 (ADR-025 dropped the postcard/remote path).
2026-06-23 13:30:21 +00:00
4078a8d8d5 tasks: mark vault/irpc-removal completed 2026-06-23 13:23:05 +00:00
7e3300e83a refactor(vault): remove irpc actor dispatch — direct method calls on VaultServiceHandle (task: vault/irpc-removal)
ADR-025 / drift item #4: remove the irpc-based actor dispatch from the vault
crate. VaultServiceHandle (Arc<std::sync::RwLock<>>) is now the sole synchronous
API. Removed: VaultProtocol enum, VaultServiceActor, VaultService wrapper,
Client<VaultProtocol> usage, irpc/irpc-derive/tokio deps, postcard dev-dep,
Serialize/Deserialize on VaultServiceError. lib.rs re-exports match the vault
README Public API. The vault is now local-only by construction with zero async
runtime dependency.

Refs: docs/architecture/crates/vault/README.md drift #4
Implements: ADR-025

# Conflicts:
#	Cargo.lock
2026-06-23 13:22:13 +00:00
9028fca302 refactor(vault): remove irpc actor dispatch — direct method calls on VaultServiceHandle (ADR-025)
Drop the irpc-based actor dispatch path from alknet-vault and convert to
direct method calls on VaultServiceHandle (drift item #4, ADR-025).

Removed:
- VaultProtocol enum with #[rpc_requests] derive from protocol.rs
- VaultServiceActor (mpsc + oneshot dispatch loop) from service.rs
- VaultService wrapper struct (only the handle is needed)
- Client<VaultProtocol> usage
- irpc, irpc-derive, tokio from [dependencies]
- postcard from [dev-dependencies]
- VaultMessage/VaultProtocol/VaultServiceActor re-exports from lib.rs
- Serialize/Deserialize derives from VaultServiceError
- postcard round-trip tests from protocol.rs
- actor tokio::test tests from service.rs

The vault now has zero async runtime dependency and zero RPC framework
dependency — it is local-only by construction. VaultServiceHandle is the
sole API: Arc<std::sync::RwLock<VaultServiceInner>> with synchronous
methods. lib.rs re-exports match the vault README Public API section.

Also fixes pre-existing clippy field_reassign_with_default warnings in
cache.rs tests so cargo clippy -- -D warnings passes.
2026-06-23 13:20:28 +00:00
e9d8896309 tasks: mark vault/cache-zeroization-test completed 2026-06-23 13:19:48 +00:00
f413719971 test(vault): add zeroization tests for cache eviction and clear (task: vault/cache-zeroization-test)
Drift item #6: verify HashMap::clear()/remove()/replace drop CachedKey values
triggering ZeroizeOnDrop. Adds drop_tracker module proving Drop semantics,
plus LRU eviction, TTL expiry, and clear() tests. The lock()-clears-cache
criterion is covered by existing test_lock_clears_all_cache_entries in service.rs.

Refs: docs/architecture/crates/vault/README.md drift #6
2026-06-23 13:18:52 +00:00
389a9e93f7 test(vault): add zeroization tests for cache eviction and clear
Adds tests verifying that HashMap::clear() and remove() drop CachedKey
values (triggering ZeroizeOnDrop), plus explicit tests for LRU eviction,
TTL expiry, and clear() removing all entries. Resolves drift item #6.

- drop_tracker module: proves HashMap::clear/remove/replace drop values
  via a Drop-flag instrumented type mirroring CachedKey's zeroize-on-drop
- test_lru_eviction_drops_evicted_cached_key: cache exceeds max_entries,
  oldest evicted
- test_ttl_expiry_evicts_entry_on_access: short TTL, wait, entry gone
- test_clear_removes_all_entries_and_empties_cache: empty after clear
- lock() clears cache already covered by test_lock_clears_all_cache_entries
2026-06-23 13:17:42 +00:00
ff50ccea09 tasks: mark core/crate-init completed 2026-06-23 13:14:06 +00:00
963f3d9532 feat(core): initialize alknet-core crate with module skeleton
Create crates/alknet-core with Cargo.toml (dependencies, feature flags
quinn/iroh), src/lib.rs declaring types/auth/config/endpoint modules, and
skeleton files for each module with doc comments and TODO markers. Add the
crate to the workspace members list.

Both quinn (default-on) and iroh (opt-in) are optional and can be active
simultaneously per ADR-010. Dual license MIT OR Apache-2.0 inherited from
the workspace.
2026-06-23 13:12:49 +00:00
6056492128 tasks: mark vault/osrng-iv-generation completed 2026-06-23 13:12:10 +00:00
3a48b11e8b vault: use OsRng for AES-GCM IV and salt generation (task: vault/osrng-iv-generation)
Drift item #1: replace rand::random() thread-local RNG with rand::rngs::OsRng
CSPRNG for security-critical IV (12-byte GCM nonce) and salt (32-byte) generation.

Refs: docs/architecture/crates/vault/README.md drift #1
Implements: ADR-020
2026-06-23 13:11:42 +00:00
f43246b978 vault: use OsRng for AES-GCM IV and salt generation
Replace rand::random() with rand::rngs::OsRng for cryptographic nonce
and salt generation in encryption.rs. rand::random() uses thread-local
RNG which may not be a CSPRNG on all platforms; OsRng reads from the
OS entropy source, preventing catastrophic IV reuse under AES-GCM.

Drift item #1 (security-critical).
2026-06-23 13:09:07 +00:00
098fd8b9b9 tasks: decompose vault, core, call crates into 28 atomic implementation tasks
Break down the three initial crates (alknet-vault, alknet-core, alknet-call)
into dependency-ordered task files for implementation agents.

Structure:
- tasks/vault/ (10 tasks) — drift fixes from ADR-025/026 refactor, review,
  spec sync. Vault is independent and can run fully in parallel with core/call.
- tasks/core/ (6 tasks) — crate init, core types, config, auth, endpoint,
  review. Core is foundational; call depends on it.
- tasks/call/ (12 tasks) — split into registry/ and protocol/ topic subdirs
  reflecting the two subsystems. CallAdapter is the merge point.

Key decisions:
- Drifts 3+9+10 grouped as one task (key-versioning-rotation) — the complete
  ADR-021 rotation feature that doesn't compile in pieces
- Reviews injected at end of each crate phase (vault, core, call)
- Vault spec-sync task removes the drift table and bumps doc status to stable
- ACME deferred in core/endpoint (noted as TODO; X509 manual certs for now)
- OperationEnv kept as a trait (load-bearing for ADR-024 layering)

Validated: 28 tasks, no cycles, 11 generations of parallel work.
Critical path runs through call (11 tasks). Vault completes by generation 4.
6 high-risk tasks identified (21%): irpc-removal, endpoint, operation-context,
operation-env, call-adapter, abort-cascade.
2026-06-23 12:41:47 +00:00
2e34590522 docs(architecture): resolve review #003 — type/API surface completeness
Review #003 found 11 critical, 14 warning, and 6 suggestion findings
after reviews #001 (governance/security) and #002 (cross-document
consistency/two-way-door audit) were resolved. The theme: types and
APIs that were *referenced* but never *defined*, and stale ADR sketches
that didn't match the now-updated spec docs.

Critical fixes (11):

- C1: DerivedKey #[derive(Deserialize)] contradicted the custom
  Deserialize that rejects "[REDACTED]" — dropped the derive, added
  explicit manual Serialize/Deserialize impls (protocol.md).
- C2: encrypt prose said "derived at PATHS::ENCRYPTION" but the
  signature takes key_version — updated to encryption_path_for_version
  (service.md).
- C3: derive_encryption_key returned DerivedKey, derive_encryption_key
  _for_version returned EncryptionKey (same cache) — unified on
  DerivedKey, defined CachedKey (service.md).
- C4: tokio vs std::sync::RwLock contradiction — specified
  std::sync::RwLock, dropped tokio from vault deps (ADR-018, ADR-025,
  service.md).
- C5: Missing drift rows in vault README — added #9 (key_version
  ignored) and #10 (rotate not implemented).
- C6: ADR-022 build_root_context and invoke() sketches omitted
  abort_policy (9 fields vs 10) — added the field to both sketches.
- C7: Capabilities type referenced 20+ times, never defined — added
  struct definition to core-types.md with Clone+Send+Sync, Zeroize,
  sealed builder API, immutability guard.
- C8: SessionOverlaySource on CallAdapter but never defined, crate
  violation (alknet-call can't depend on alknet-agent) — defined the
  trait in alknet-call (call-protocol.md), matching the IdentityProvider
  pattern.
- C9: CompositeOperationEnv dispatch fall-through was "a two-way door"
  — added contains() to OperationEnv trait, made the composite probe
  before dispatching, eliminating the sentinel ambiguity.
- C10: No API for Layer 2 (connection overlay) registration, CallConnection
  undefined — defined CallConnection struct + register_imported() API
  (call-protocol.md).
- C11: with_local signature diverged between two examples (4 args vs 5)
  — added capabilities as the 5th arg, made both examples consistent.

Warning fixes (14):

- W1: invoke_with_policy restructured as required method, invoke gets a
  default impl delegating to it — eliminates duplication across impls.
- W2: CachedKey defined (service.md).
- W3: EncryptionKey constructor/glue specified, added to re-export list.
- W4: Secp256k1ExtendedPrivKey defined, derive_ethereum_key glue shown.
- W5: encryption_path_for_version rejects version < 2 (v1 is TS PBKDF2).
- W6: Wire payload schemas for all event types + ResponseEnvelope →
  EventEnvelope conversion table (call-protocol.md).
- W7: Timeout section — deadline on OperationContext, composed calls
  inherit parent's deadline, CallAdapter::with_timeout().
- W8: Request ID generation spec — UUID v4 for composed calls, wire ID
  vs internal ID relationship for abort cascade.
- W9: unlock_new already-unlocked behavior specified (returns
  AlreadyUnlocked).
- W10: KeyType Serialize/Deserialize justification corrected (stale
  irpc reference removed).
- W11: OperationProvenance and CompositionAuthority defined inline in
  operation-registry.md (were only in ADR-022).
- W12: encrypt/decrypt free functions marked pub(crate), relationship
  to VaultServiceHandle methods stated.
- W13: rotate signature removed from encryption.md (it's a
  VaultServiceHandle method, not a free function).
- W14: CallAdapter::new() + with_session_source() + with_timeout()
  constructors shown.

Suggestion fixes (6): Seed: Clone note, VaultServiceInner invariant,
ExtendedPrivKey accessor signatures, CURRENT_KEY_VERSION location, ADR-018
stale actor text, derivation helpers re-export note.
2026-06-23 10:56:05 +00:00
cb98f42cd4 docs(architecture): resolve review #002 remaining Tier 4 findings
Add ADR-026 (vault key model — HD derivation) recording the foundational
HD-derivation decision, 74' coin type reservation, SLIP-0010/Ed25519
default, secp256k1 feature-gating, and AES-256-GCM cipher choice. These
were previously inline rationale with no ADR (W9).

Extend ADR-018 with an explicit EncryptedData wire format lock — fields,
encoding, and semantics are frozen; no removal without a format-version
migration (W10).

Resolve the remaining guard clauses and spec decisions:

- W2: Capabilities must be immutable after construction (no interior
  mutability). Makes the Arc vs deep-copy clone semantics genuinely
  two-way.
- W5: Published to_* specs are compatibility contracts — best-effort
  mappings are two-way before first publication, one-way after. Version
  generated specs.
- W6: Salt field clarification — v2 salt is permanently unused; a future
  KDF is a different derivation family, not a version-indexed path; the
  field saves a wire-format change only.
- W7: unlock_new returns Zeroizing<String> — the mnemonic is the root of
  trust and must not linger in freed memory.
- W17: OQ-09 WASM — server-side dispatch door is honestly closed
  (Connection is concrete, tokio-bound), not implicitly preserved.
- W18: OQ-10 git — composability fork (raw smart protocol vs call-protocol
  projection) is a separate decision from ERC721 scope.
- W20: from_openapi must prefix imported error codes (HTTP_404) to avoid
  collision with protocol-level codes (NOT_FOUND). Normative rule, not
  naming convention.
- W21: ScopedOperationEnv field is private — construction via new()/
  empty(), query via allows(). Makes the future subgraph refactor
  non-breaking.
- C13: Connection::set_identity — the endpoint does not read identity()
  after handle() returns (Connection is moved into the spawned task).
  Observability is handler-side logging. Simplest honest answer.
- W1: OperationAdapter trait is async, returns Vec<HandlerRegistration>.
  from_call requires async discovery; ADR-022 changed the return type.
- W11: CompositionAuthority::as_identity() defined — constructs a
  synthetic Identity (label as id, scopes, resources) not resolvable via
  IdentityProvider. Second Identity construction path, acknowledged.
- W14: SecretKey is iroh::SecretKey (Ed25519) — consistent with the
  endpoint's iroh dependency.
- W19: Grandchild abort propagation is inherit-by-default (option a) —
  invoke() with no explicit policy inherits parent's policy. ContinueRunning
  auto-propagates to grandchildren unless explicitly overridden.
2026-06-23 08:20:27 +00:00
91159bf574 docs(architecture): remove derive_password and site_password_path from vault
The password-manager pattern (deterministic per-site passwords from HD
derivation) is not relevant to an RPC system's vault. Handlers call APIs
(using API keys, OAuth tokens, mTLS), not websites with passwords. The
vault is for cryptographic key derivation and credential encryption.

Removes:
- derive_password, derive_password_string from service.md
- site_password_path from mnemonic-derivation.md
- m/74'/1'/0'/{hash}' path from PATHS module and path semantics table
- derive_password row from the cache table

Resolves review #002 C9 (site_password_path hash mapping underspecified)
by removing the feature rather than specifying the non-standard
string→u32 mapping and Ed25519-as-password-entropy construction.

If deterministic password generation is ever needed (browser-automation
edge case), it can be re-added — the cost is near-zero. Removing it now
eliminates permanent API surface inherited from a prior project's
password-manager pattern.
2026-06-23 06:06:11 +00:00
7dda6eec68 docs(architecture): add ADR-025 — vault local-only dispatch, drop irpc
Drops irpc from alknet-vault entirely. The vault's dispatch is now direct
method calls on VaultServiceHandle — no VaultProtocol enum, no
VaultMessage, no VaultServiceActor, no mpsc channel, no Service trait, no
RemoteService trait, no postcard serialization. The vault is local-only by
construction.

The core security argument: irpc made the vault remote-capable by default
(RemoteService generated unless no_rpc is passed). The IrohProtocol handler
forwards all messages without auth. The docs framed 'register an ALPN' as a
server-setup change. This is the default-insecure anti-pattern — security
should be opt-in, not opt-out. ADR-025 inverts the default: local-only is
the only mode, and remote access requires building a separate vault-server
crate (a visible architectural act, not a flag flip).

The actor path was already dead code — service.md said 'prefer
VaultServiceHandle directly — no channel, no serialization.' The actor
existed only to make irpc's Service trait work, which existed only to make
RemoteService work, which was the footgun. VaultServiceHandle's
Arc<RwLock> provides concurrent reads and exclusive writes — better
throughput than the actor's sequential processing.

DerivedKey serialization simplifies: always redact on serialize (for
logging safety), reject '[REDACTED]' on deserialize with an error. No
'postcard preserves bytes' path. This resolves review #002 W8 (silent
corruption on JSON-deserialized DerivedKey).

Resolves:
- OQ-21: remote vault access — resolved (not deferred). Not a vault crate
  feature; if needed, a separate vault-server crate with its own ADR.
- C7: vault-server-crate question decided — not created now, not precluded.
- C8: operation access policy table dissolved — all operations local-only
  by default; if a vault-server crate exposes some remotely, that crate
  defines the policy.
- W8: DerivedKey JSON deserialization — resolved (reject redacted payloads).

Amends ADR-005 (irpc remains for alknet-call, not for alknet-vault),
ADR-018 (vault is even more standalone — zero RPC framework deps),
ADR-019 (vault is the only layer, not just the only direct-caller layer),
ADR-008 (vault integration point unchanged, but now local-only by
construction).
2026-06-22 14:53:52 +00:00
cdf340bec7 docs(architecture): add ADR-024 — operation registry layering, resolve C6
Diagnoses a conflation in the pre-ADR-024 spec: the OperationRegistry
inherited immutability by analogy from ADR-010's HandlerRegistry (ALPN-level),
but the TLS-config argument that justifies HandlerRegistry immutability does
not apply to the operation registry, which lives behind a single ALPN
(alknet/call). This made from_call (which discovers ops over a live connection
at runtime) structurally incompatible with the blanket immutability claim.

ADR-024 layers the operation registry by trust boundary: curated (Local) ops
are static and immutable — the startup trust boundary is where their
composition authority is granted; session (Session) and imported (FromCall
etc.) ops are dynamic at their respective scopes (per-session, per-connection)
— their trust boundaries are per-scope, not per-startup. The principle:
immutability follows the trust boundary. Immutability is the security control
for composing ops (can escalate privilege); provenance + composition authority
are the controls for non-composing ops (can't escalate).

The OperationEnv trait becomes the integration point (Arc<dyn OperationEnv>),
following the IdentityProvider precedent (ADR-004): the CallAdapter composes
the root OperationContext.env per incoming call from the active layers
(curated base + connection overlay + session overlay). Children inherit the
parent's composite env by Arc::clone — overlay composition happens once at
the root and propagates through the composition tree.

Resolves review #002 C6 (OperationContext.env type identity crisis): the
field is split into scoped_env: ScopedOperationEnv (reachability data, from
the registration bundle) and env: Arc<dyn OperationEnv + Send + Sync>
(dispatch trait object). One field was being used as two different types
(reachability set with .allows() and dispatch trait with .invoke());

Localizes W4 (hot-swap ↔ registry mutability coupling) to the connection
scope: no global mutable registry to hot-swap; overlays replace naturally
with connect/disconnect and session start/end. Schema-drift on reconnect is
a per-connection overlay-rebuild concern, not a global hot-swap protocol.

Partially addresses W3 (CallClient registry security): the registry-shape
sub-question is resolved by the overlay model; the capability-exposure
sub-question (what capabilities a remote peer can trigger) remains for
ADR-017 — ADR-024 does not overclaim resolution there.

Amends OQ-04 to scope its immutability claim to the HandlerRegistry and
cross-reference ADR-024 for the operation registry. Generalizes OQ-19's
session-overlay mechanism to also cover connection-scoped remote imports —
both are per-scope dynamic overlays on the static curated base, using the
same trait-layering mechanism.
2026-06-22 13:44:58 +00:00
c62a6adc7b docs(architecture): resolve review #002 Tiers 1-3 — mechanical and consistency fixes
Governance (Tier 2):
- Advance ADR-022 and ADR-023 from Proposed to Accepted (specs already
  depend on their types as source of truth)
- Amend ADR-015: mark Decision 3 and Assumption 6 as superseded by ADR-022;
  update handler_identity type to CompositionAuthority
- Amend ADR-002: note handle() signature revised by ADR-007 (BiStream → Connection)
- Amend ADR-004: note 'enrich/replace' AuthContext language superseded by
  ADR-011's immutability model; update to describe set_identity on Connection
- Update main README ADR table to show ADR-022/023 as Accepted

Spec-ADR consistency (Tier 3):
- Add abort_policy: AbortPolicy field to OperationContext struct (ADR-016
  Decision 6 mandated this but the spec omitted it)
- Define AbortPolicy enum (AbortDependents | ContinueRunning) with Default impl
- Add abort_policy to build_root_context and LocalOperationEnv::invoke()
- Define the OperationEnv trait explicitly with invoke() and
  invoke_with_policy() methods (was referenced as 'must remain a trait'
  but never defined)
- Specify From<StreamError> for HandlerError impl with exact variant mapping
- Add Connection::from_quinn() / from_iroh() constructors (was referenced
  as Connection::new() but never defined)
- Remove undefined CertAuthorityEntry placeholder from AuthPolicy v1 (will
  be added additively when alknet-ssh lands)
- Fix config.md key-differences table: rate limits are in DynamicConfig,
  not StaticConfig

Mechanical fixes (Tier 1):
- overview.md: 'closes the QUIC stream' → 'closes the connection' (stale
  from pre-ADR-007 model)
- overview.md: OQ-04 entry updated from stale 'defer to implementation'
  to 'resolved: static at startup'
- mnemonic-derivation.md: remove duplicate helper functions block (incomplete
  first copy, complete second copy)
- ADR-003: add iroh (feature-gated) to alknet-core dependency list, added
  by ADR-010
- ADR-021: fix ambiguous 'W1 drift issue from the vault review' cross-reference
- ADR-022: rephrase FromCall 'leaf locally' to 'leaf in the local registry'
- ADR-017: add error_schemas to from_call mirror list and services/schema
  step (inconsistency with ADR-023)
- ADR-016: fix self-referential citation ('ADR-016 Assumption 5' → 'Assumption 5')
- Add ScopedOperationEnv::empty(), allows(), new() and
  CompositionAuthority::none(), new() impl blocks (referenced but undefined)
- Add call.completed clarification for non-subscription calls
- Add services/schema leading-slash normalization note
- Crate README ADR tables: add missing ADR-013 (call), ADR-015 (core),
  ADR-006 + ADR-010 (vault)
- Vault README: add consolidated 'Known Source Drift' table tracking all
  four drift items (OsRng, unwrap, CURRENT_KEY_VERSION, spawn bug) in one
  place, including the two previously missing from README
2026-06-22 05:46:37 +00:00
8f8a8a48f9 docs(reviews): add pre-implementation architecture sanity check #002
Second pre-implementation review. Goes wider than #001 on cross-document
consistency and the two-way-door framing from ADR-009.

Finds 13 critical, 21 warning, 12 suggestion issues:
- Governance: ADR-022/023 are Proposed but specs treat them as binding;
  ADR-015/002/004 (Accepted) contradict later refinements without supersession
  markers
- Abort policy (ADR-016) missing from OperationContext struct; OperationEnv
  trait never defined
- OperationContext.env type identity crisis (reachability set vs dispatch
  trait)
- ADR-017 from_call mirror list missing error_schemas; OperationAdapter trait
  stale vs ADR-022 bundle
- OQ-21 remote vault 'non-breaking' framing conflicts with ADR-019 and hides
  a crate-decomposition decision; RemoteService path unvalidated
- Vault operation access policy table incomplete for security-sensitive methods
- site_password_path string-to-index mapping breaks determinism guarantee
- Two-way-door audit: ADR-022 narrowed several doors without updating OQ
  classifications; 'published artifact is a contract' blind spot in framework

Includes recommended 5-pass resolution order.
2026-06-22 05:09:39 +00:00
3f529df367 docs(architecture): update ADR-015 scoped env API — resolved by ADR-022
ADR-015 L171 said the scoped env API was 'a two-way door for
implementation.' ADR-022 has now resolved it: ScopedOperationEnv with
operation-level granularity (HashSet<String>), not namespace-level.
Update the stale text to point to the resolution.
2026-06-21 10:51:42 +00:00
6a7f8f91ad docs(architecture): resolve S1 — abort policy on OperationContext, not wire
ADR-016 Decision 6 specifies that the abort policy (abort-dependents vs
continue-running) is set on OperationContext and propagated through
OperationEnv::invoke() — the composing handler decides the child's
policy, not the wire caller. The call.requested payload does not carry
an abort policy field. This resolves the TBD that was masquerading as a
two-way door: two of the three options ADR-016 floated (wire payload,
per-operation declaration) were inconsistent with the ADR's own
assumptions.

Also marks review #001 as resolved — all 5 critical, 4 warning, and 4
suggestion findings are now addressed.
2026-06-21 10:34:12 +00:00
3e238a471b docs(architecture): add ADR-023, resolve OQ-24 — operation error schemas
ADR-023 adds error_schemas to OperationSpec so operations can declare
their domain-level failure modes (FILE_NOT_FOUND, RATE_LIMITED, etc.)
distinct from protocol-level codes (NOT_FOUND, FORBIDDEN, etc.). The
call.error payload gains an optional 'details' field carrying the typed
error payload conforming to the declared schema. from_openapi/to_openapi
map OpenAPI response status codes to/from ErrorDefinitions, making the
adapter contract from ADR-017 faithful on the error axis.

Also fixes W2 (KeyVersionMismatch stale comment in encryption.md —
ADR-021 implements rotation without this variant) and W4
(derive_encryption_key_for_version missing from service.md method list).

Spec updates: operation-registry.md (OperationSpec, ErrorDefinition,
Handler error mapping, services/schema), call-protocol.md (call.error
payload, CallError, ResponseEnvelope), README.md, overview.md,
open-questions.md (OQ-24), call/README.md, encryption.md, service.md.
2026-06-21 10:26:18 +00:00
1cedc4eeba docs(architecture): add ADR-022, resolve OQ-23 — handler registration, provenance, and composition authority
ADR-022 wires the three controls ADR-015 specified but left without
registration paths (C1-C4 from review #001): composition authority,
scoped env, and capabilities now enter through a HandlerRegistration
bundle. Provenance (Local, FromOpenAPI, FromMCP, FromCall, Session)
determines which ops can compose — leaves don't get composition
authority. CompositionAuthority replaces handler_identity: Identity
(it's a declared authority bundle, not a peer identity). Capabilities
are per-request from the bundle (resolves closure-capture vs context
ambiguity). Kernel/user analogy: user's authority checked at External
gate; handler's composition authority used inside; scoped env bounds
reachability.

Also fixes W1 (stale ADR-020 path example) and W3 (from_mcp missing
from adapter lists in operation-registry.md).

Spec updates: operation-registry.md (OperationRegistry,
HandlerRegistration, OperationContext, OperationEnv, registration
example, capability injection), call-protocol.md (build_root_context),
README.md, overview.md, open-questions.md (OQ-23), call/README.md.
2026-06-21 09:09:47 +00:00
ec315e9499 docs(research): extend alknet-filesystem POC — distributed sync via automerge CRDT
Third POC iteration (alknet-fs-sync-poc, 9/9 tests) proves multi-node
path-tree sync:

- Path tree modeled as automerge CRDT document, synced via automerge's
  sync protocol over iroh QUIC connections
- Each node has a local replica; writes are local + immediate (no
  network latency); sync is async, gossip-style, eventually consistent
- Concurrent writes to different paths converge cleanly; concurrent
  writes to same path resolve via LWW (NFS-equivalent semantics)
- Content (blobs) and metadata (path tree) sync separately — automerge
  for path edges, iroh-blobs for file bytes
- Branch inheritance works through automerge sync

Key finding: automerge concurrent put_object on same key creates a
conflict, not a merge. Root structures must be created by one node and
synced before other nodes write. This is a design constraint for the
spec.

24 total tests pass across both POC crates. All remaining unknowns are
implementation-scope, not feasibility blockers.
2026-06-20 17:36:39 +00:00
209831d922 docs(research): add alknet-filesystem POC summary — SQLite path-tree + iroh content store + honker
Validates the three-layer architecture for a content-addressed, branch-aware,
mountable filesystem:

- SQLite path tree over iroh-blobs MemStore (15/15 tests pass)
- Fossil-style branching with free content dedup via BLAKE3 content addressing
- honker-core for notify-on-commit inside the same transaction as path-tree
  mutations (transactional outbox pattern)
- Write path: "branch on write, merge on close" reconciles BLAKE3-must-hash-
  complete-file with chunked filesystem writes; concurrent readers see old
  version until close commits atomically; crash/abort leaves old version intact
- Multi-tenancy via bucket_id column (free isolation, auth is an adapter problem)

Remaining unknowns (FsStore/redb coexistence, distributed incomplete-blob reads,
SFTP wiring, GC/tag management, branch chain depth) are implementation-scope,
not feasibility blockers.
2026-06-20 16:37:05 +00:00
b7b5337586 docs(research): add metatensor format — schema-driven binary tensor layout
Documents the metatensor format: a binary data format where a TypeBox/jsonschema
schema describes the layout of binary data at schema-computed offsets. Extends
safetensors (fixed TensorRef schema) to arbitrary schemas, enabling struct tensors
(records), blob tensors (variable-length via indirection), and nested layouts.

Key points:
- TypeBox schemas render to standard JSON Schema; the jsonschema Rust crate
  validates them with zero translation. Custom typedef.ts kinds (TFloat32,
  TInt32, TStruct) map to jsonschema custom keywords via with_keyword().
- This eliminates typebox-rs as a schema engine — replaced by jsonschema +
  a small offset-computation module + ~50 lines of custom keyword impls.
- Three tensor kinds: flat (safetensor today), struct (record of typed fields),
  blob (struct tensor as index + flat tensor as data store, for variable-length)
- Memory-mappable: parse header, compute offsets, mmap data, typed views per
  schema. No copy, no deserialization.
- QUIC-streamable: header is one small JSON message, each tensor is a separate
  stream. Lazy loading, parallel transfer, incremental compute.
- ujsx-authorable: <Tensor>, <Struct>, <Field> as layout components, same
  reconciler that diffs UI trees diffs model schemas. Model versioning is
  tree diffing.
- Category-theory foundation: ujsx as universal typed-tree IR, HostConfig as
  interpreter. <Tensor> is no stranger than <div>.
2026-06-20 14:09:04 +00:00
f11522aaa4 docs(research): extend alknet-tensor — flowgraph as compute graph layer, petgraph port
Adds a major section documenting how @alkdev/flowgraph (already npm-published,
uses ujsx) becomes the compute graph authoring and execution layer for
alknet-tensor, replacing webgpu-torch's imperative nn.Module hierarchy and
autograd recording with declarative ujsx templates and reactive DAG execution.

Key points documented:
- The ujsx tree IS the compute graph (CUDA-graphs-shaped but declarative)
- flowgraph's two HostConfigs: GraphologyHostConfig (compile/validate) and
  ReactiveHostConfig (execute with signal-driven status propagation)
- nn modules become ujsx components, autograd becomes reverse tree walk
- Conditional/Map components enable dynamic structure CUDA graphs can't express
- Network-callable compute graphs (mix local + remote ops in one template)
- TSX authoring via standard JSX→h transform (ujsx jsx-runtime as target)
- graphology → petgraph port: ~15 API methods map 1:1, removes ~5400 lines of JS
- Updated POC priorities: end-to-end skeleton now includes flowgraph integration,
  petgraph host port as a separate POC
2026-06-20 12:03:31 +00:00
7d7b99c04d docs(research): add alknet-tensor architecture summary — Rust+wgpu tensor lib with quickjs API layer
Documents the architectural direction for a PyTorch-shaped tensor computation
library built on Rust + wgpu, where QuickJS is a thin API/composition layer
and Rust owns memory, dispatch, and WGSL codegen. Derived from webgpu-torch
as the reference design (op_spec → opgen → WGSL shader pipeline) but not a
port of its code — webgpu-torch is the reference, alknet-tensor is the
production architecture.

Key decisions: JS holds handles (BufferId), Rust owns wgpu::Buffers; ~4-5
high-level Rust ops (create_tensor/dispatch_kernel/register_kernel/read/write)
not ~20 low-level GPU API calls; WgslGenerator as a third handlebars backend
in typebox-rs codegen alongside RustGenerator and TypeScriptGenerator; tensor
ops as OperationSpecs on the registry (network-callable over irpc, verified
protocol-compatible on quickjs by POC 2).

Documents the downstream problems this solves as a side effect: distributed
compute over irpc, LLM-authored model code (toolEnv pattern), edge/embedded
tensor compute, the compositing problem sidestepped (compute has no surface),
and cross-platform by construction (wgpu's many backends).
2026-06-20 11:48:57 +00:00
940bc9c1dc docs(research): extend alknet-desktop POC summary — operations protocol verified on quickjs
The quickjs-reactive-probe was extended to load @alkdev/operations (registry,
call protocol, response envelopes, ACL, buildCallHandler) alongside the
reactive core. All five operations assertions pass on QuickJS-NG via rquickjs:
registry/execute/envelope/acl/callHandler. 271 modules loaded total.

This closes the third highest-leverage unknown: the operations protocol is
runtime-agnostic in practice, not just in theory. Adds a new section on the
QuickJS UDF host convergence — a minimal isolate speaking the same bidirectional
operations protocol as the TypeScript reference, the Rust alknet-call port,
and the planned NAPI/Python adapters, without needing Node/Deno/Bun. Connects
to the toolEnv WASM-QuickJS sandbox precedent at /workspace/toolEnv.
2026-06-20 11:04:13 +00:00
d64bc915b7 docs(reviews): add pre-implementation architecture gap review #001
Captures 5 critical, 4 warning, 4 suggestion findings from a sanity
check of the core, call, and vault crate specs against ADRs 001-021
and the OQ tracker. Criticals cluster on one tangle: the registration
API surface in operation-registry.md doesn't carry the handler
identity, scoped env, or capabilities that ADR-014/015 lock as 'set at
registration' — plus a missing error-schema concept for adapters.
2026-06-20 10:13:30 +00:00
969a66774a docs(research): add alknet-desktop POC summary — headless WebGPU + quickjs reactive probe
Captures the two completed POCs that resolve the highest-leverage unknowns
around the alknet-desktop direction (Rust + wgpu + rquickjs + ujsx over three.js):

- ui-spoke-poc: headless WebGPU rendering in Deno, three.js WebGPURenderer via
  device-capture, MSDF text (the '2D UI is rocket surgery' subproblem)
- quickjs-reactive-probe: @preact/signals-core + @alkdev/typebox + @alkdev/ujsx
  reconciler verified compatible with QuickJS-NG via rquickjs

Documents the rejected deno-desktop alternative, the established architectural
direction (head-worker over irpc/ALPN, two HostConfigs over one wgpu surface),
headless/headed parity via llvmpipe, the supply-chain surface reduction, and
the open unknowns that remain before SDD can begin.
2026-06-20 07:13:45 +00:00
9087f0579f docs(architecture): document vault remote capability, enrich OQ-21
The VaultProtocol is a remote-capable irpc service by construction —
#[rpc_requests] generates both Service (local) and RemoteService (remote)
trait impls. DerivedKey's dual serialization (JSON redacts, postcard
preserves) was designed for this. Enabling remote vault access is a
server-setup change, not a protocol change.

OQ-21 enriched with full context:
- What's already in place (protocol, serialization, actor, auth transport)
- What's not in place (IrohProtocol handler forwards all messages without
  auth checks; needs NodeId allowlist + message filtering in assembly layer)
- Operation access policy: Unlock/Lock local-only; Derive/Encrypt/Decrypt
  remote-capable
- Use case: machine node → workers (workers don't hold mnemonics)
- Per-machine-node vaults, not shared (compartmentalization)
- Breaking vs non-breaking analysis (enabling = non-breaking; protocol
  evolution = wire break, manageable via ALPN versioning)

The auth-wrapping handler lives in the assembly layer (or a dedicated
vault-server crate depending on both alknet-core and alknet-vault), not in
the vault crate itself — the vault is standalone (ADR-018) and can't
import alknet-core's auth model.

OQ-21 remains deferred — no commitment to implement, but the door is open
and the design space is mapped.
2026-06-20 06:48:23 +00:00
dc27753680 docs(architecture): add ADR-021, resolve OQ-22 — key rotation via version-indexed paths
Key rotation uses version-indexed derivation paths: each key version maps
to a distinct SLIP-0010 path (m/74'/2'/0'/{version-2}'). v2 is at index 0
(PATHS::ENCRYPTION), v3 at index 1, etc.

Mechanism:
- encryption_path_for_version(version) constructs the path
- decrypt derives the key at the version-indicated path (not always
  PATHS::ENCRYPTION)
- rotate(blob, to_version) decrypts with old key, re-encrypts with new
- No new mnemonic needed — same seed, different path
- Partial rotation is safe — old keys remain derivable
- The vault does not self-rotate; the assembly layer iterates blobs

Source drift flagged:
- decrypt currently ignores key_version for path selection (always uses
  PATHS::ENCRYPTION) — must use version-indexed paths
- rotate method does not exist in source — must be added
- CURRENT_KEY_VERSION must bump from 1 to 2 (per ADR-020, reinforced here)

OQ-22 resolved. Only OQ-21 (remote vault admin, deferred) remains.
2026-06-19 10:09:20 +00:00
6e9414bc81 docs(architecture): add ADR-020, resolve OQ-20 — HD derivation for encryption keys
The vault uses SLIP-0010 HD derivation from the BIP39 seed for the
AES-256-GCM encryption key, not PBKDF2. This replaces the TypeScript
predecessor's (@alkdev/storage/src/graphs/crypto.ts) PBKDF2-based
approach.

Key decisions:
- HD derivation at m/74'/2'/0'/0' produces the encryption key
- PBKDF2 is not implemented in the vault; no password-based derivation
- salt field is unused in v2 (wire-format compat only)
- key_version=1 reserved for TS PBKDF2 data; key_version=2 for vault HD
- TS-encrypted data requires one-time migration to v2
- CURRENT_KEY_VERSION changes from 1 to 2 (source drift flagged)

OQ-20 resolved: the encryption key derivation method is locked. OQ-22
(key rotation workflow) remains open but does not block implementation.
2026-06-19 09:49:06 +00:00
dd1ca1de70 docs(architecture): add alknet-vault spec, ADR-018, ADR-019, OQ-20/21/22
Spec the vault crate from its existing implementation. The vault is
stable (implementation exists); this spec documents what IS so the
implementation-sync agent can reconcile source drift.

New spec documents (crates/vault/):
- README.md — crate index, security constraints, public API
- mnemonic-derivation.md — BIP39, SLIP-0010, BIP-0032, derivation paths
- encryption.md — AES-256-GCM, EncryptedData, key versioning, salt
- service.md — VaultServiceHandle lifecycle, actor dispatch, cache
- protocol.md — VaultProtocol irpc messages, DerivedKey redaction

New ADRs:
- ADR-018: Vault as standalone crate (zero alknet deps; own types/errors)
- ADR-019: Vault assembly-layer-only access (CLI is sole caller)

New open questions:
- OQ-20: Salt/KDF Phase B (open, low priority — salt field reserved)
- OQ-21: Remote vault administration (deferred — needs ADR if ever needed)
- OQ-22: Key rotation mechanism (open, low priority — workflow not specced)

Spec-vs-source drift explicitly flagged (for the sync agent):
- rand::random() used for IVs instead of OsRng (security-critical)
- unwrap() on every RwLock acquisition (must use unwrap_or_else)
- ADR-038 / OQ-SVC-03 references in source comments are stale (old numbering)
- VaultServiceActor::spawn returns a non-functional second actor (source bug)
- KeyVersionMismatch error variant is defined but unused in v1
2026-06-19 09:23:47 +00:00
40f6468e18 docs(architecture): fix spec/ADR inconsistencies from pre-decomposition review
Critical:
- operation-registry: remove stale duplicate OperationEnv impl that
  propagated parent.metadata through composition (violated ADR-014);
  collapse to one canonical block with metadata: HashMap::new()
- operation-registry: fix request_id collision — format!("env-{name}")
  produced identical IDs across concurrent invocations, corrupting
  PendingRequestMap correlation and the abort-cascade tree (ADR-016)
- operation-registry + ADR-015: fix OperationContext.internal visibility —
  pub field let handlers mark their own call internal (privilege
  escalation per ADR-015); change to pub(crate) with pub fn is_internal

Warnings:
- core-types: add Connection::set_identity/identity (OQ-11) to the
  Connection type spec — was specified in auth.md but missing from the
  type definition
- operation-registry: add Capabilities: Clone design note — invoke()
  clones capabilities through composition; explicit security implication
- call-protocol: add CallAdapter root OperationContext construction
  example showing internal: false wire path, complementing
  OperationEnv::invoke() internal: true composition path
- overview: remove alknet/agent from ALPN registry — agent is a future
  consumer of alknet-call (call-protocol operations), not a separate ALPN
- call-protocol: clarify call.requested payload schema and the
  leading-slash convention (wire operationId has slash, registry name
  does not)

Suggestions:
- operation-registry: cross-reference ResponseEnvelope definition
- core-types: add StreamError to HandlerError mapping table
2026-06-19 09:13:10 +00:00
400c60e7f4 docs(architecture): security constraints from security review
Address security review findings by adding explicit constraints to specs
and implementation specialist role:

Architectural constraints (spec updates):
- metadata does not propagate through OperationEnv::invoke() — fresh
  HashMap for nested calls, closes the back-door leak channel where a
  handler that puts a secret in metadata would leak it to children and
  across from_call to remote nodes (ADR-014)
- Config reload must be authenticated/local-only — malicious reload =
  root-equivalent privilege grant (config.md)
- from_call trust is transitive — scoped env bounds reachability, not
  what the remote op does (operation-registry.md)
- Token entropy ≥128 bits — prefix is lookup aid not secret, offline
  hash verification requires high-entropy tokens (auth.md)

Implementation constraints (auth.md security constraints section + role spec):
- OsRng for cryptographic nonces (AES-GCM IV reuse is catastrophic)
- CachedKey derives Zeroize/ZeroizeOnDrop (no secrets in freed heap)
- No unwrap()/expect() outside tests (poisoned lock recovery, not crash)
- Implementation specialist role spec updated with all four constraints
2026-06-19 06:55:54 +00:00
c0a322ac29 docs(architecture): resolve OQ-11 and OQ-19 — all open questions resolved
OQ-11 (handler-level auth observability): Option B — handlers store
resolved identity on Connection via set_identity. Two identity scopes:
connection-level (observability, write-once-read-many) and per-request
(ACL, on OperationContext). Per-request takes precedence for ACL;
connection-level is for logging/audit only.

OQ-19 (session-scoped registries): Protocol doesn't need changes.
OperationEnv must remain a trait (not concrete) to enable session-overlay
pattern. Three-tier registry: core (static, External+Internal), session
(dynamic, Internal-only), promotion (curated review). Documented as
implementation guard in operation-registry.md.

All 19 open questions are now resolved. No open one-way or two-way doors
remain. The architecture is ready for review and implementation.
2026-06-19 06:05:04 +00:00
8f19eb8861 docs(architecture): add ADR-017 call protocol client and adapter contract, resolve OQ-15
ADR-017 locks the client/adapter architecture:
- CallClient opens QUIC connections, shares dispatch loop with CallAdapter
- Connection direction independent of call direction (both sides can call)
- from_call adapter: discovers remote ops via services/list + services/schema,
  registers with forwarding handlers (same pattern as from_openapi/from_mcp)
- to_openapi/to_mcp: project local ops to external protocols
- OperationAdapter trait: produces (OperationSpec, Handler) pairs
- Cross-node call tree: abort cascade propagates through from_call handlers
- Credentials from capabilities (ADR-014), adapter ops Internal by default (ADR-015)

The dispatch POC at /workspace/@alkdev/dispatch demonstrated head/worker over
SSH+axum; under the call protocol it's cross-node composition via from_call.
Connection topology (who advertises, who opens) is independent of call
direction — runner pattern, dispatch pattern, and P2P all work.
2026-06-18 10:57:29 +00:00
e2730869ca docs(architecture): add ADR-016 abort cascade for nested calls, resolve OQ-17
ADR-016 locks the abort cascade model:
- call.aborted cascades to all non-terminal descendants via parent_request_id
- Default policy: abort-dependents (abort everything downstream)
- Opt-in: continue-running (started descendants continue, pending ones abort)
- Server (CallAdapter) discovers descendants and propagates; client sends one abort
- Handlers clean up via Rust async drop semantics (Drop guards)
- parent_indexed map suffices for tree walking; flowgraph is optional prior art

Spec updates:
- call-protocol.md abort cascade section references ADR-016
- OQ-17 resolved, ADR-016 referenced across all call crate specs
- README.md updated: ADRs 001-016, OQ-17 moved to resolved
2026-06-18 09:37:19 +00:00
6285779c30 docs(architecture): add ADR-015 privilege model and authority context, resolve OQ-18
ADR-015 locks the call protocol's security model:
- internal flag switches authority context to handler identity, not skip ACL
- Operations have External/Internal visibility (Internal returns NOT_FOUND from wire, excluded from services/list)
- OperationContext carries both identity (caller/principal) and handler_identity (handler/agent)
- Scoped composition env bounds reachability (handler can only invoke declared operations)
- Three controls together: visibility (wire boundary) + handler identity (authority) + scoped env (reachability) = least privilege

Spec updates:
- OperationSpec gains Visibility field (External/Internal)
- OperationContext gains handler_identity field
- AccessControl section: ACL runs against caller identity for external, handler identity for internal
- LocalOperationEnv propagates handler_identity
- services/list only returns External operations
- Adapter-registered operations are Internal by default
- OQ-18 resolved, ADR-015 referenced across all call crate specs
2026-06-18 08:55:34 +00:00
b4aadc6b93 docs(architecture): add OQ-19 session-scoped registries and agent-written operations
Document the three-tier registry model (core/session/promotion) and the
self-improving agent workflow where agents write their own operations in
a quickjs sandbox. The POC at /workspace/toolEnv demonstrated the sandbox
mechanism (quickjs in Deno web workers, proxy-based env bridge via
postMessage) but exposed the full registry to the sandbox — the security
gap that OQ-18's scoped composition env addresses.

The call protocol doesn't need changes: the OperationEnv trait is the
composition point, and a session-scoped env wraps the global env (session
registry first, fall through to global). The one-way door this OQ guards
against: making OperationEnv concrete instead of a trait, or hardcoding
the global registry into the dispatch path, would close the session-overlay
pattern. Session-scoped operations are always Internal, run under the
handler's identity, and are ephemeral. Promotion to core requires curation
review (architect role with promote scope).
2026-06-18 08:31:46 +00:00
f27d717ac8 docs(architecture): reframe OQ-17 and OQ-18 as protocol-level concerns, not agent-specific
The abort cascade and privilege model are call protocol semantics that
every consumer inherits — NAPI adapter, Python adapter, agent service, and
any future service speaking the EventEnvelope wire format. Framing them as
'needs agent crate in view' let a single consumer's timeline gate a
protocol-level decision. The agent use case is a useful test case for edge
cases, but the decisions belong to the call protocol.
2026-06-18 07:47:57 +00:00
fab2c88444 docs(architecture): rename trusted to internal, add OQ-17 abort cascade and OQ-18 privilege model
The 'trusted' flag on OperationContext was the wrong word — it implies a
trust decision was made, but what actually happens is the call originated
internally (from composition) not externally (from the wire). Renamed to
'internal' with clarified semantics: internal calls switch authority
context to the handler's identity, not skip ACL. This prevents the
privilege escalation vector where composition with 'trusted: true' bypassed
all access control (buggy handler + parameterized dispatch).

- Rename trusted -> internal across operation-registry.md, ADR-014
- Update OperationContext field description and LocalOperationEnv code
- Add OQ-17: abort cascade for nested calls (call.aborted cascades to
  descendants, default abort-dependents, continue-running opt-in). One-way
  door on the protocol event schema; mechanism is a two-way door.
- Add OQ-18: privilege model and authority context (internal = authority
  switch not ACL skip, External/Internal operation visibility, scoped
  composition env + handler identity). Needs agent crate in view.
- Add abort cascade section and constraint to call-protocol.md
- Update crates/call/README.md with OQ-17, OQ-18, and two new design principles
- Update architecture README.md with OQ-17, OQ-18
2026-06-18 07:38:33 +00:00
6a7d4b9755 docs(architecture): add ADR-014 secret material flow, remove vault ops from call protocol
Resolve the contradiction between ADR-008's "capability source" model
and operation-registry.md showing vault operations on the wire. ADR-014
establishes: vault is assembly-layer only, capabilities carry outbound
credentials (distinct from inbound identity), call protocol carries no
secret material, adapters take credential sources not static tokens.

- Add ADR-014 (Secret Material Flow and Capability Injection)
- Remove vault/derive, vault/unlock, vault/decrypt from call protocol
  registration examples and all spec examples
- Add Capabilities field to OperationContext, propagate through
  LocalOperationEnv nested calls
- Add Capability Injection section to operation-registry.md
- Add no-secret-material wire constraint to call-protocol.md
- Add streaming subscribe example (LLM chat with Vercel UI chunks)
- Add Security Model section to overview.md (identity vs capabilities)
- Trim WASM treatment from ~20 lines to a design-constraint note
- Add OQ-16 (resolved: no vault ops on wire), update OQ-08, OQ-15
- Update ADR-003, ADR-008, ADR-013 to remove stale "via call protocol"
  vault references
2026-06-18 03:16:45 +00:00
6219a323b6 docs(architecture): untangle TLS identity use cases, remove phase framing, add ADR-013 Rust canonical + agent crate
- Rewrite OQ-12: separate two distinct TLS identity use cases (RFC 7250
  raw keys as default for P2P, X.509 for domain-hosted/browsers) instead
  of conflating them as 'file paths now, ACME later'. ACME is a proven
  pattern from the reverse-proxy project, not speculative future work.

- Resolve OQ-13 and OQ-14: remove 'Phase 1' framing from core crate
  specs. /{service}/{op} is the correct design for alknet-call, not a
  simplification. Batch as correlated call.requested events is the correct
  protocol design. Core crates need to be done right from the start.

- Add ADR-013: Rust as canonical implementation language. TypeScript
  @alkdev/operations is a reference that informed the design, not a
  parallel implementation. The only JS use case is browser SDK adaptation.
  Five reasons: memory safety, LLM competence, supply chain attacks,
  performance, browser-only JS.

- Add alknet-agent crate to the crate graph (depends on alknet-call, not
  alknet-core). Agent service uses call protocol client for tool dispatch
  and vault/derive for provider keys — no env vars for secrets. ALPN
  alknet/agent added to the registry.

- Add OQ-15: call protocol client and adapter contract. alknet-call needs
  both server (CallAdapter) and client (remote invocation over QUIC), plus
  the adapter traits (from_*, to_*) that enable composition.

- Clarify alknet-napi as thin NAPI projection layer, not business logic.

- Fix bugs: ProtocolController → ProtocolHandler typo, OperationEnv
  invoke() path format inconsistency, RateLimitConfig comment confusion.

- Update endpoint.md TLS section: comprehensive identity model comparison
  table, RFC 7250 as default mode, ACME as proven pattern.
2026-06-17 09:32:44 +00:00
a596f0d188 docs(architecture): add alknet-call crate spec, ADR-012, resolve OQ-07
Add architecture specs for the alknet-call crate:

- call-protocol.md: CallAdapter, EventEnvelope wire format, bidirectional
  stream model with ID-based correlation, PendingRequestMap, protocol
  operations (call/subscribe/batch/schema), per-request identity resolution,
  connection/stream lifecycle, error codes

- operation-registry.md: OperationSpec, async Handler type, OperationRegistry,
  AccessControl with trusted call bypass, OperationEnv with context
  propagation (parent_request_id, identity inheritance), service discovery,
  irpc integration layering, naming convention (no leading slash in names)

- ADR-012: Call protocol uses bidirectional QUIC streams with EventEnvelope
  framing and ID-based correlation. Protocol is stream-agnostic and symmetric.
  Resolves OQ-07.

Key design decisions:
- Handler type is async (Fn returning Pin<Box<dyn Future>>)
- OperationEnv::invoke propagates parent context (identity, metadata,
  parent_request_id)
- Identity resolution is per-request, not per-connection
- Operation names without leading slash (fs/readFile, not /fs/readFile)
- Batch is a client-side pattern, not a protocol primitive (OQ-14)
- Phase 1 uses service/op paths, node prefix added later (OQ-13)

Also: promote ADR-010 and ADR-011 from Proposed to Accepted, add OQ-13
and OQ-14 to open-questions.md.
2026-06-16 14:22:20 +00:00
bd4055ff70 docs(architecture): add RFC 7250 raw public key identity model
iroh uses RFC 7250 raw Ed25519 public keys for TLS instead of X.509
certificates. rustls already supports this. This means the quinn
endpoint can also use raw public keys — same key-based identity model
as iroh, but with direct QUIC over UDP. X.509 is optional, needed
only for domain-facing identity (browser/WebTransport clients).

Update StaticConfig with TlsIdentity enum (X509, RawKey, SelfSigned)
and add iroh_relay field. Remove 'iroh deferred' language — iroh is
a first-class connectivity mode.
2026-06-16 13:01:00 +00:00
e3d1a504da docs(architecture): clarify iroh ALPN integration — use Endpoint directly, not Router
iroh's Endpoint natively supports ALPN negotiation and set_alpns(). Our
HandlerRegistry dispatches exactly like iroh's own ProtocolMap/Router
pattern, but shared across both quinn and iroh connection sources. We
use iroh::Endpoint directly (not iroh::Router) because our HandlerRegistry
and AuthContext are shared across sources.
2026-06-16 12:44:19 +00:00
5c8448ff86 docs(architecture): fix OQ-05 — multi-connectivity endpoint, not multi-transport
Correct the conflation of quinn/TLS/iroh as interchangeable transports.
They are complementary connectivity modes serving different deployment
contexts: quinn (public IP + TLS), iroh (NAT traversal via relay), TCP
(handler-specific, not core). Clarify that TLS cert = network identity,
not auth identity. Map stealth mode to HTTP handler on standard ALPNs
instead of byte-peeking. Resolve OQ-05 as one-way door. SendStream/
RecvStream now use internal enum dispatch for both quinn and iroh
streams.
2026-06-16 12:41:03 +00:00
90d5f4eaf9 docs(architecture): spec alknet-core with per-crate subdocs, ADR-010/011
Add alknet-core architecture specs in docs/architecture/crates/core/ with
focused subdocuments for core types, endpoint, auth, and config. Write
ADR-010 (ALPN Router and Endpoint) defining AlknetEndpoint, HandlerRegistry,
accept loop, and graceful shutdown. Write ADR-011 (AuthContext Structure)
defining AuthContext fields, immutability in handle(), and IdentityProvider
injection pattern. Resolve OQ-04 (static registration), OQ-12 (file paths
only for v1). Add OQ-11 (auth observability). Fix remaining alknet-secret
references to alknet-vault across ADRs 003/004/005/009.
2026-06-16 12:07:17 +00:00
80128a56e5 refactor: rename alknet-secret to alknet-vault
Rename the crate from alknet-secret to alknet-vault to better reflect its
purpose as a local key vault (seed management, key derivation, encryption)
rather than a network service.

Symbol renames:
- SecretService → VaultService
- SecretServiceHandle → VaultServiceHandle
- SecretServiceActor → VaultServiceActor
- SecretServiceError → VaultServiceError
- SecretProtocol → VaultProtocol
- SecretMessage → VaultMessage
- ServiceLocked → VaultLocked
- alknet_secret → alknet_vault (crate name)

Update ADR-008 with vault access pattern: the vault is a capability source,
not a service endpoint. The CLI injects derived/decrypted material into
operation contexts — handlers never hold vault references.
2026-06-16 11:10:07 +00:00
b47a6fe70b docs(architecture): resolve one-way doors, clean up Phase 0 specs
Resolve blocking one-way door decisions:
- ADR-007: BiStream is a trait, handlers receive Connection not BiStream
- ADR-008: Secret service is CLI-embedded, exposed via call protocol
- ADR-009: One-way door decision framework (classify by reversal cost)

Update existing documents:
- overview.md: add design principles, revise ProtocolHandler signature,
  update shared types, add WASM as design constraint
- open-questions.md: add door-type classifications, resolve OQ-01/OQ-08,
  move OQ-09/OQ-10 to deferred section, mark two-way doors as impl-deferred
- README.md: reflect resolved questions, remove crate spec stubs from index
- ADR-002: cross-reference ADR-007 for signature revision

Clean up premature artifacts:
- Remove 11 empty crate spec stubs (16-28 lines each, no unique content)
- Specs will be created when each crate enters Phase 1
2026-06-16 10:43:31 +00:00
f77b515968 docs(architecture): add Phase 0 architecture specs for ALPN-as-service model
Foundational architecture documents following the SDD process:

ADRs:
- 001: ALPN-based protocol dispatch (one endpoint, ALPN negotiation)
- 002: ProtocolHandler trait (replaces StreamInterface/MessageInterface)
- 003: Crate decomposition (one crate per handler, core provides shared infra)
- 004: Auth as shared core (IdentityProvider, hybrid resolution model)
- 005: irpc as call protocol foundation
- 006: ALPN string convention and connection model (alknet/ prefix, one ALPN per connection)

Docs:
- overview.md: crate graph, shared types, ALPN registry, failure modes
- README.md: index with doc table, ADR table, lifecycle definitions
- open-questions.md: 10 OQs across 7 themes (3 resolved, 7 open)

Crate spec stubs for all 11 planned crates (alknet-core through alknet CLI).

Key decisions resolved during self-review:
- AuthContext resolution is hybrid: endpoint resolves TLS-level auth,
  handlers resolve protocol-level auth (resolves OQ-02)
- ALPN is per-connection not per-stream, corrected ADR-001 (resolves OQ-06)
- ALPN naming uses alknet/ prefix without versions (resolves OQ-03)
- HandlerError return type on ProtocolHandler trait
- alknet/secret removed from ALPN registry until OQ-08 resolved
2026-06-15 22:14:58 +00:00
b5a4600d74 greenfield: clean slate for ALPN-as-service pivot
Delete old source crates (alknet-core, alknet, alknet-napi), old
architecture docs (ADRs, specs, open questions), old research docs
(phase2, event-sourcing, feasibility, etc.), old tasks, and obsolete
reference material (gitserver/MPL, honker, nats, rustfs, polyglot,
keystone, distributed-identity).

Keep: alknet-secret (standalone, compiles), pivot docs, iroh and ssh
references, rudolfs reference (MIT/Apache, fork candidate), ops docs,
sdd_process.md, and licenses.

Previous implementation preserved at /workspace/@alkdev/alknet-main/
for reference during porting.

Workspace compiles: cargo check + 14 tests pass for alknet-secret.
2026-06-15 12:08:08 +00:00
d003a4f4ec docs(research): revise cleanup plan to follow SDD process
Phase 5 now references the architect role and SDD process from
docs/sdd_process.md instead of creating ad-hoc spec stubs. Added
key new ADRs and architecture docs the architect will need to produce.
Updated gitserver reference note (MPL concern, archive it).
Kept rudolfs reference (MIT/Apache, fork candidate).

Also removed 'needs-update' status from the lifecycle states since
it's not part of the SDD process — stale docs get annotated with a
note and existing status, not a new status.
2026-06-15 09:17:07 +00:00
dc661dff82 docs(research): add pre-pivot cleanup plan
Plan to archive obsolete architecture docs, mark superseded ADRs,
remove replaced code modules (interface layer, stealth mode, control
channel), annotate stale-but-keeping docs, and create pivot spec stubs.

Key decisions:
- MPL gitserver reference archived (licensing risk + gix is the target)
- MIT/Apache rudolfs reference kept (fork candidate for git LFS)
- ADRs marked superseded, not deleted (historical record)
- Code deletion limited to modules the pivot explicitly replaces
2026-06-15 08:43:52 +00:00
503 changed files with 75297 additions and 52339 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
target/
node_modules/
.worktrees/

View File

@@ -244,6 +244,46 @@ last_updated: 2026-05-29
and migration notes belong in commit messages or separate migration docs.
7. **Lifecycle states**: Every doc has a status. Draft → reviewed → stable →
deprecated. Stale `draft` docs are a sign of unfinished work.
8. **Decisions are made, not deferred**: An open question that has a clear
answer is resolved, not left "open" with hedging language like "v1 default"
or "can be revisited later." If the decision is made, mark it resolved. If
the decision genuinely can't be made yet (the use case isn't concrete,
the options aren't clear), leave it open — but say *why* it can't be made,
not "we'll decide later." The architect's job is to make architecture
decisions, not to defer them to the implementation agent.
## Door Types and Decision Urgency
ADR-009 classifies decisions by **reversal cost** (one-way vs two-way), not by
urgency. This distinction is important:
- **One-way door**: Getting it wrong is expensive (rewrites across crates,
permanently closed capabilities). Requires an ADR before implementation.
Gets the deliberation it deserves.
- **Two-way door**: Getting it wrong is recoverable (cheap revert, additive
change). Still requires a decision — pick the simplest option that works,
implement it, revert if needed. The decision is made; what's cheap is the
reversal, not the decision.
**Door type ≠ deferral.** A two-way door is not a license to leave a decision
unmade. Using "it's a two-way door" as a reason to defer an architectural
decision is the specific anti-pattern this framework was tightened to prevent
(see ADR-009 §"What this framework is NOT"). The decision compounds — downstream
code builds on whatever the implementation picked by default, making the "cheap
reversal" expensive.
**Architecture decisions are the architect's, regardless of door type.** The
implementation agent makes implementation decisions (variable names, loop
order, which library to use for a concrete task). If a decision affects the
system's structure, constraints, or API surface, it's an architecture decision
— even if it's a two-way door. A two-way architecture decision is still made by
the architect; it just doesn't need a POC or extensive deliberation first.
**Deferral is separate.** Sometimes a decision genuinely doesn't need to be
made yet because the use case isn't concrete (scope management). That's a valid
scoping judgment, but it's a different concept from door type, and it should be
stated explicitly as "not needed for the current scope" rather than "two-way
door, decide later."
## Anti-Patterns to Avoid
@@ -258,6 +298,17 @@ last_updated: 2026-05-29
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
write an ADR
7. **No README index**: Without the index table, ADRs and docs are unfindable
8. **Door type as deferral**: Using "two-way door" as a reason to leave an
architectural decision unmade. Door type classifies reversal cost, not
urgency. A two-way door is a decision you make now and can revert later —
not a decision to defer. If the decision is made, mark the OQ resolved. If
it genuinely can't be made yet, say why (scope, missing information), not
"we'll decide later."
9. **Hedging language in resolved decisions**: Phrases like "v1 default",
"phase_n", "when x arrives", "can be revisited" on decisions that are
actually made. If the decision is made, state it cleanly. Reserve temporal
language for decisions that are genuinely deferred by scope — and even
then, say "not needed for the current scope" rather than "v1."
## When to Redirect

View File

@@ -212,12 +212,24 @@ Read `AGENTS.md` at project root for full details. Key rules:
1. **No comments in code** — Per project convention.
2. **Error handling** — Use `anyhow::Result` for application code, `thiserror` for
library error types. Never panic in library code.
3. **Feature flags** — Transports are feature-gated (`tls`, `iroh`, `acme`). Base
3. **No `unwrap()` or `expect()` outside tests** — These are debug signals that
something wasn't clear. If you reach for `unwrap()`, it means the error
handling path wasn't specified — stop and think about what should actually
happen on that error. For poisoned locks, use
`unwrap_or_else(|e| e.into_inner())` or explicit error propagation. A panic
in one operation must not cascade to other operations.
4. **Cryptographic nonces use `OsRng`** — AES-GCM IVs and any other cryptographic
nonces must use `OsRng` (or equivalent CSPRNG), never `rand::random()`. IV
reuse under the same key is catastrophic for GCM.
5. **Secret material is zeroized on drop** — Any type holding derived keys,
decrypted credentials, or other secret material must derive `Zeroize` and
`ZeroizeOnDrop`. Secrets must not linger in freed heap memory.
6. **Feature flags** — Transports are feature-gated (`tls`, `iroh`, `acme`). Base
crate should compile lean.
4. **Async runtime** — `tokio` is the async runtime. All I/O is async.
5. **Naming conventions** — Rust standard: `snake_case` for functions/variables/
7. **Async runtime** — `tokio` is the async runtime. All I/O is async.
8. **Naming conventions** — Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants.
6. **Module structure** — One module per component under `src/`. Re-export via
9. **Module structure** — One module per component under `src/`. Re-export via
`mod.rs` or `lib.rs` as appropriate.
## Key Principles

2365
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[workspace]
members = [
"crates/alknet-vault",
"crates/alknet-core",
"crates/alknet",
"crates/alknet-napi",
"crates/alknet-secret",
"crates/alknet-call",
"crates/alknet-http",
]
resolver = "2"

242
README.md
View File

@@ -1,233 +1,37 @@
# Alknet
> **Status: Alpha** — This project is in early development. It depends on solid libraries (russh, tokio, iroh) for core functionality, but the glue code and integration between them has not been fully vetted for production use. Because alknet operates low in the network stack, bugs can cause serious problems downstream (leaked connections, broken tunnels, auth failures). Use with caution and report issues.
> **Status: Pre-alpha** — This project is undergoing a major architectural pivot to an ALPN-as-service model. The previous implementation has been archived and a greenfield rebuild is in progress.
A self-hostable SSH-based tunnel tool that provides VPN-like functionality without being a VPN protocol.
A self-hostable networking toolkit built on QUIC+TLS with ALPN-based protocol dispatch. Each protocol handler (SSH, SFTP, Git, HTTP, DNS, messaging, call protocol) registers an ALPN string on a shared endpoint. The ALPN negotiation during the TLS/QUIC handshake routes connections to the correct handler before any application bytes are read.
## What it does
## Core Insight
- **Private tunneling** — Route traffic to internal services (Postgres, Redis, APIs) over SSH
- **Censorship circumvention** — SSH over TLS on port 443 is indistinguishable from HTTPS to DPI
- **NAT traversal** — The iroh transport enables peer-to-peer connections without public IPs or port forwarding
- **Service mesh connectivity** — Lightweight transport layer for event systems via reserved `alknet-*` destinations
The core insight: SSH tunnels work because SSH is fundamental infrastructure. Blocking it breaks the internet. Alknet makes SSH tunneling accessible through a simple CLI with pluggable transports.
## Quick start
### Build
```bash
cargo build --release
```
The default build includes TLS and iroh transports. To build a minimal binary with just TCP:
```bash
cargo build --release --no-default-features -p alknet
```
### Server
```bash
# Generate a host key
ssh-keygen -t ed25519 -f ssh_host_ed25519_key -N ""
# Start the server on port 22 (TCP)
alknet serve --key ssh_host_ed25519_key \
--authorized-keys ~/.ssh/authorized_keys
# TLS with stealth mode (looks like nginx 404 to scanners)
alknet serve --key ssh_host_ed25519_key \
--transport tls \
--acme-domain example.com \
--stealth
# iroh (no public IP needed)
alknet serve --key ssh_host_ed25519_key \
--transport iroh
```
### Client
```bash
# Connect via TCP and start a SOCKS5 proxy on 127.0.0.1:1080
alknet connect --server example.com:22 \
--identity ~/.ssh/id_ed25519
# Connect via TLS
alknet connect --server example.com:443 \
--transport tls \
--identity ~/.ssh/id_ed25519
# Connect via iroh (peer-to-peer, no public IP)
alknet connect --peer <endpoint-id> \
--transport iroh \
--identity ~/.ssh/id_ed25519
# With port forwarding
alknet connect --server example.com:22 \
--identity ~/.ssh/id_ed25519 \
--forward 5432:db.internal:5432 \
--forward 6379:redis.internal:6379
```
### Use the SOCKS5 proxy
Once connected, point any SOCKS5-aware application at `127.0.0.1:1080`:
```bash
curl --socks5 127.0.0.1:1080 http://internal-api:8080/health
```
For VPN-like "route all traffic" behavior, use [tun2proxy](https://github.com/tun2proxy/tun2proxy) alongside alknet's SOCKS5 proxy (see [ADR-014](docs/architecture/decisions/014-defer-tun-recommend-socks5-proxy.md)).
**A service IS an ALPN.** One endpoint, one port, many protocols — dispatched by the TLS handshake, not by application-level peeking or separate listeners.
## Crates
| Crate | Description |
|-------|-------------|
| `alknet-core` | Core library: transport trait, SOCKS5 server, port forwarding, auth, server handler |
| `alknet` | CLI binary (`alknet connect` / `alknet serve`) |
| `alknet-napi` | Node.js native addon via napi-rs (`connect()` / `serve()`) |
| Crate | Status | Description |
|-------|--------|-------------|
| `alknet-vault` | stable | Local key vault: BIP39/SLIP-0010/AES-GCM key derivation and encryption |
| `alknet-core` | planned | ProtocolHandler trait, ALPN router, auth/identity, config |
| `alknet-ssh` | planned | SSH handler (russh), SOCKS5, port forwarding |
| `alknet-call` | planned | JSON-RPC call protocol (EventEnvelope framing) |
| `alknet-fs` | planned | Content-addressed file storage (iroh-blobs backend) |
| `alknet-sftp` | planned | SFTP handler (russh-sftp protocol core) |
| `alknet-git` | planned | Git smart protocol handler (gix) |
| `alknet-http` | planned | HTTP handler (axum, REST API, MCP) |
| `alknet-dns` | planned | DNS handler (hickory-proto, pkarr) |
| `alknet-msg` | planned | E2E encrypted messaging, mixnet support |
| `alknet` | planned | CLI binary (assembles and registers handlers) |
## Feature flags
## Documentation
| Feature | Crate | Default | Description |
|---------|-------|---------|-------------|
| `tls` | `alknet-core`, `alknet` | yes | TLS transport (tokio-rustls) |
| `iroh` | `alknet-core`, `alknet` | yes | iroh QUIC P2P transport |
| `acme` | `alknet-core` | no | ACME/Let's Encrypt auto-cert provisioning |
| `testutil` | `alknet-core` | no | Test utilities (for internal use) |
- [ALPN-as-service architecture](docs/research/pivot/alpn-service-architecture.md) — pivot proposal
- [Cleanup plan](docs/research/pivot/cleanup-plan.md) — greenfield transition plan
- [SDD process](docs/sdd_process.md) — spec-driven development process
- [Research references](docs/research/references/) — iroh, russh, russh-sftp deep dives
## Transport modes
| Transport | Client | Server | Notes |
|-----------|--------|--------|-------|
| **TCP** | `--transport tcp --server addr:port` | `--transport tcp --listen addr:port` | Direct SSH over TCP. Default. |
| **TLS** | `--transport tls --server addr:port` | `--transport tls --tls-cert/--tls-key or --acme-domain` | SSH wrapped in TLS. Looks like HTTPS. |
| **iroh** | `--transport iroh --peer <id>` | `--transport iroh` | QUIC P2P via iroh. No public IP needed. |
## Authentication
- **Ed25519 public keys** — Default. Load authorized keys from a file via `--authorized-keys`.
- **OpenSSH certificate authority** — Optional. Use `--cert-authority` for multi-user deployments.
- **No password authentication** — Key-based auth only (see [ADR-012](docs/architecture/decisions/012-auth-ed25519-and-cert-authority.md)).
Key formats are OpenSSH throughout (private keys: `-----BEGIN OPENSSH PRIVATE KEY-----`, public keys: `ssh-ed25519 AAAA...`). PEM-encoded keys (PKCS#1, PKCS#8) are not supported.
## Architecture
Alknet's core architectural decision is that SSH never touches the network directly. The transport layer produces a duplex byte stream, and SSH runs over it via `russh::client::connect_stream()` / `russh::server::run_stream()`. This makes transports fully pluggable.
```
Client Server
│ transport.connect() │ transport_acceptor.accept()
│ ─────────────────────────────────────────────▶│
│ (duplex byte stream established) │
│ russh::client::connect_stream(stream) │ russh::server::run_stream(stream, handler)
│ ═══════ SSH session over stream ═════════════ │
│ channel_open_direct_tcpip(host, port) │
│ ─────────────────────────────────────────────▶│
│ ┌─────── TCP proxy ──────────────────┐ │
│ │ SSH channel ←→ TcpStream::connect │ │
│ └────────────────────────────────────┘ │
```
See [docs/architecture/](docs/architecture/) for full specifications and [ADR index](docs/architecture/README.md).
## Node.js API
The `alknet-napi` crate provides a Node.js native addon via napi-rs:
```js
const { connect, serve } = require('alknet-napi');
// Client: open a duplex stream through SSH
const stream = await connect({
server: "example.com:22",
transport: "tcp",
identity: "/path/to/key",
});
const data = await stream.read(1024);
await stream.write(Buffer.from("hello"));
await stream.close();
// Server: accept connections and receive streams
const server = await serve({
transport: "tcp",
hostKey: "/path/to/host_key",
authorizedKeys: "/path/to/authorized_keys",
listen: "0.0.0.0:22",
});
server.onConnection((event) => {
const { stream, info } = event;
// handle stream
});
```
### iroh (peer-to-peer)
iroh transport eliminates the need for public IPs or port forwarding. Both sides discover each other through a relay, then establish a direct QUIC connection. This is ideal for services behind NAT, distributed systems, or any scenario where opening ports is impractical.
```js
// Server: starts an iroh endpoint and prints its peer ID
const server = await serve({
transport: "iroh",
hostKey: "/path/to/host_key",
authorizedKeys: "/path/to/authorized_keys",
irohRelay: "https://relay.iroh.network/", // optional, defaults to iroh's relay
proxy: "socks5://proxy.example.com:1080", // optional, for restrictive networks
});
console.log("iroh endpoint ID:", server.endpointId);
// e.g. iroh endpoint ID: abc23xyz...
// Clients connect using that peer ID
const stream = await connect({
peer: server.endpointId,
transport: "iroh",
identity: "/path/to/key",
irohRelay: "https://relay.iroh.network/", // must match the server's relay
proxy: "socks5://proxy.example.com:1080", // optional
});
```
The `endpointId` property returns the server's z-base-32 encoded iroh node ID. Share this ID with clients so they can connect — no DNS, no public IP, no port forwarding required.
### TLS
TLS transport wraps SSH in TLS, making the connection indistinguishable from HTTPS traffic to deep packet inspection:
```js
// Server
const server = await serve({
transport: "tls",
hostKey: "/path/to/host_key",
authorizedKeys: "/path/to/authorized_keys",
listen: "0.0.0.0:443",
tlsCert: "/path/to/cert.pem",
tlsKey: "/path/to/key.pem",
});
// Client
const stream = await connect({
server: "example.com:443",
transport: "tls",
identity: "/path/to/key",
tlsServerName: "example.com", // optional, SNI hostname
insecure: true, // accept self-signed certs (dev only)
});
```
## Status and stability
This is **alpha software**. While it depends on well-established libraries (russh, tokio, rustls, iroh) for SSH, async I/O, TLS, and QUIC respectively, the integration layer that ties them together has not been battle-tested. Potential concerns include:
- **Connection handling edge cases** — reconnection logic, graceful shutdown, resource cleanup
- **Security review** — the auth layer, rate limiting, and stealth mode should be audited before production use
- **API stability** — the library API (`alknet-core`) and NAPI interface may change between versions
- **Performance** — no load testing or benchmarking has been done yet
Please test thoroughly and [file issues](https://git.alk.dev/alkdev/alknet/issues) for any problems you encounter.
Reference implementation (previous architecture) is preserved at `/workspace/@alkdev/alknet-main/`.
## License

View File

@@ -0,0 +1,35 @@
[package]
name = "alknet-call"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Structured RPC over QUIC on ALPN `alknet/call`: operations, streaming subscriptions, service discovery"
repository.workspace = true
[lib]
name = "alknet_call"
[features]
default = ["quinn"]
quinn = ["dep:quinn", "dep:rustls", "dep:rustls-native-certs", "dep:rustls-pemfile", "alknet-core/quinn"]
[dependencies]
alknet-core = { path = "../alknet-core" }
irpc = { workspace = true }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
tracing = "0.1"
thiserror = "2"
uuid = { version = "1", features = ["v4"] }
futures = "0.3"
parking_lot = "0.12"
quinn = { version = "0.11", optional = true }
rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] }
rustls-native-certs = { version = "0.8", optional = true }
rustls-pemfile = { version = "2", optional = true }
[dev-dependencies]
rcgen = "0.13"
hex = "0.4"

View File

@@ -0,0 +1,947 @@
//! `CallClient`: the outbound connection opener (ADR-017 §1).
//!
//! Opens a QUIC connection to a remote node on ALPN `alknet/call`, performs
//! credential setup, and produces a [`CallConnection`] running the shared
//! dispatch loop (delegated to [`crate::protocol::dispatch::Dispatcher`]).
//! `CallClient` is the connection-establishment half; `CallAdapter`'s accept
//! path is the inbound half; both produce a `CallConnection` and hand it to
//! the same `Dispatcher::run_loop` (ADR-017 §1).
//!
//! After establishment the connection is symmetric (ADR-017 §2): both sides
//! can send and receive `call.requested`. The `CallClient` is both a caller
//! (initiates outgoing calls via `CallConnection::call()`/`subscribe()`/
//! `abort()`) and a callee (dispatches incoming calls against its registry).
//!
//! See `docs/architecture/crates/call/client-and-adapters.md` for the spec.
use std::net::SocketAddr;
use std::sync::Arc;
use alknet_core::auth::IdentityProvider;
use alknet_core::config::TlsIdentity;
use alknet_core::types::Connection;
use crate::protocol::connection::CallConnection;
use crate::protocol::dispatch::Dispatcher;
use crate::registry::registration::OperationRegistry;
/// Expected identity of the remote node (ADR-017 §7, extended by ADR-034 §2).
/// Carries a fingerprint string the assembly layer derives from `Capabilities`
/// when the local node has a `PeerEntry` for the remote (the known-peer case →
/// fingerprint pin).
///
/// `remote_identity: None` is the **public X.509 endpoint** case: the local
/// node has no `PeerEntry` for the remote, so there is no fingerprint to pin.
/// Combined with an X.509 transport, `None` selects CA verification
/// (`WebPkiServerVerifier`) per the verifier-selection rule in ADR-034 §3.
/// Combined with an Ed25519 raw-key transport, `None` fails closed (raw-key
/// remotes are always known peers — no CA to fall back to).
///
/// The `Option` is therefore load-bearing, not cosmetic: `Some(fingerprint)`
/// means "pin this" (known peer), `None` means "trust the CA or fail"
/// (unknown remote). An implementer must not default `remote_identity` to a
/// placeholder value to "satisfy" the field — `None` is a real state that
/// drives verifier selection.
#[derive(Debug, Clone)]
pub struct RemoteIdentity {
pub fingerprint: String,
}
/// Credentials for an outbound `alknet/call` connection (ADR-017 §7). All
/// three dimensions come from `Capabilities` (ADR-014), never from environment
/// variables — see the No-Env-Vars Invariant in
/// `docs/architecture/crates/call/client-and-adapters.md`.
#[derive(Debug, Clone, Default)]
pub struct CallCredentials {
/// The local node's TLS identity (RFC 7250 raw key or X.509), derived
/// from the vault at startup.
pub tls_identity: Option<TlsIdentity>,
/// Opaque call-protocol-level auth token, decrypted from the vault.
pub auth_token: Option<alknet_core::auth::AuthToken>,
/// Expected fingerprint/cert of the remote node, stored as a capability.
/// `Some` → fingerprint pin (known peer with a `PeerEntry`); `None` → CA
/// verification for X.509 remotes, fail-closed for Ed25519 raw-key remotes
/// (ADR-034 §2/§3). `None` is the public-X.509-endpoint state, not a
/// missing field — must not be defaulted to a placeholder.
pub remote_identity: Option<RemoteIdentity>,
}
impl CallCredentials {
pub fn new() -> Self {
Self::default()
}
pub fn with_tls_identity(mut self, tls_identity: TlsIdentity) -> Self {
self.tls_identity = Some(tls_identity);
self
}
pub fn with_auth_token(mut self, token: alknet_core::auth::AuthToken) -> Self {
self.auth_token = Some(token);
self
}
pub fn with_remote_identity(mut self, remote: RemoteIdentity) -> Self {
self.remote_identity = Some(remote);
self
}
}
/// Errors produced by [`CallClient::connect`].
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ClientError {
#[error("transport error: {message}")]
Transport { message: String },
#[error("tls setup error: {message}")]
TlsSetup { message: String },
#[error("connection closed")]
ConnectionClosed,
}
/// Outbound `alknet/call` connection opener (the #1 gap, ADR-017 §1).
///
/// Peer authorization flows through the existing `AccessControl::check` gate
/// in `OperationRegistry::invoke` (ADR-029 §3) — no parallel `remote_safe`/
/// `trusted_peer` gate.
pub struct CallClient {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
}
impl CallClient {
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
Self {
registry,
identity_provider,
}
}
pub fn registry(&self) -> &Arc<OperationRegistry> {
&self.registry
}
pub fn identity_provider(&self) -> &Arc<dyn IdentityProvider> {
&self.identity_provider
}
/// Open a QUIC connection to `addr` on ALPN `alknet/call`, perform
/// credential handshake, and return a `CallConnection` running the shared
/// dispatch loop. Credentials come from `Capabilities` (ADR-014), not env
/// vars — the no-env-vars invariant.
///
/// The dispatch loop runs on a spawned task; the returned `CallConnection`
/// is live until the remote closes the connection or the caller drops it.
/// The caller can immediately use `call()`/`subscribe()`/`abort()` on the
/// returned connection, and the remote peer can call back into this
/// `CallClient`'s registry (connection symmetry, ADR-017 §2).
#[cfg(feature = "quinn")]
pub async fn connect(
&self,
addr: SocketAddr,
credentials: CallCredentials,
) -> Result<CallConnection, ClientError> {
let alpn = b"alknet/call".to_vec();
let client_config = build_quinn_client_config(&credentials, &alpn)
.map_err(|e| ClientError::TlsSetup { message: e })?;
let bind_addr: SocketAddr = "0.0.0.0:0".parse().expect("valid bind addr");
let endpoint = quinn::Endpoint::client(bind_addr).map_err(|e| ClientError::Transport {
message: e.to_string(),
})?;
let connection = endpoint
.connect_with(client_config, addr, "alknet")
.map_err(|e| ClientError::Transport {
message: e.to_string(),
})?
.await
.map_err(|e| ClientError::Transport {
message: e.to_string(),
})?;
let connection = Connection::from_quinn_with_alpn(connection, alpn);
Ok(self.spawn_dispatch(connection))
}
/// Run the shared dispatch loop over a pre-established `Connection`. The
/// `CallClient` spawns the dispatcher task and returns a live
/// `CallConnection` the caller can use immediately. Used by `connect()`
/// (after the QUIC dial completes) and by integration tests that wire a
/// mock/loopback `Connection` directly.
pub fn spawn_dispatch(&self, connection: Connection) -> CallConnection {
let call_connection = Arc::new(CallConnection::new(connection));
let dispatcher = Dispatcher::new(
Arc::clone(&self.registry),
Arc::clone(&self.identity_provider),
);
let run_conn = Arc::clone(&call_connection);
tokio::spawn(async move {
dispatcher.run_loop(run_conn).await;
});
(*call_connection).clone()
}
}
#[cfg(feature = "quinn")]
fn build_quinn_client_config(
credentials: &CallCredentials,
alpn: &[u8],
) -> Result<quinn::ClientConfig, String> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let client_auth = build_client_auth(&provider, &credentials.tls_identity)?;
let verifier = select_server_verifier(&provider, &credentials.remote_identity)?;
let mut config = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_client_cert_resolver(client_auth);
config.alpn_protocols = vec![alpn.to_vec()];
config.enable_early_data = true;
Ok(quinn::ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(config).map_err(|e| e.to_string())?,
)))
}
/// Build the client-auth cert resolver that presents the local node's TLS
/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC
/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the
/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the
/// cert chain + key are loaded from disk. `None` (no `tls_identity` configured)
/// resolves to no client cert (the server gets nothing to fingerprint).
#[cfg(feature = "quinn")]
fn build_client_auth(
provider: &Arc<rustls::crypto::CryptoProvider>,
tls_identity: &Option<TlsIdentity>,
) -> Result<Arc<dyn rustls::client::ResolvesClientCert>, String> {
match tls_identity {
Some(TlsIdentity::RawKey(secret_key)) => {
let signing_key = Arc::new(Ed25519SigningKey::new(secret_key.clone()));
let spki = signing_key.spki_public_key();
let cert = rustls::pki_types::CertificateDer::from(spki.to_vec());
let certified_key = Arc::new(rustls::sign::CertifiedKey::new(vec![cert], signing_key));
Ok(Arc::new(RawKeyClientCertResolver::new(certified_key)))
}
Some(TlsIdentity::X509 { cert, key }) => {
let cert_chain = load_cert_chain(cert).map_err(|e| e.to_string())?;
let key_der = load_private_key(key).map_err(|e| e.to_string())?;
let certified_key = rustls::sign::CertifiedKey::from_der(cert_chain, key_der, provider)
.map_err(|e| e.to_string())?;
Ok(Arc::new(RawKeyClientCertResolver::new(Arc::new(
certified_key,
))))
}
Some(TlsIdentity::SelfSigned) | None => Ok(Arc::new(NoClientCertResolver)),
Some(TlsIdentity::Acme { .. }) => {
Err("ACME TLS identity is server-only; cannot be used for client auth".to_string())
}
}
}
/// Select the server cert verifier by `remote_identity` presence (ADR-034 §3).
///
/// - `Some(fingerprint)` → known peer → `FingerprintPinVerifier` (fingerprint
/// match). The fingerprint IS the trust anchor.
/// - `None` → no `PeerEntry` for the remote → `WebPkiServerVerifier` (CA
/// verification) for X.509 remotes. For Ed25519 raw-key remotes the
/// `WebPkiServerVerifier` fails closed at handshake time (raw-key remotes
/// have no CA to fall back to — ADR-034 §2 assumption 1). `None` is the
/// public-X.509-endpoint state, not "skip verification."
#[cfg(feature = "quinn")]
fn select_server_verifier(
provider: &Arc<rustls::crypto::CryptoProvider>,
remote_identity: &Option<RemoteIdentity>,
) -> Result<Arc<dyn rustls::client::danger::ServerCertVerifier>, String> {
match remote_identity {
Some(ri) => Ok(Arc::new(FingerprintPinVerifier::new(
ri.fingerprint.clone(),
provider.signature_verification_algorithms,
))),
None => {
let roots = load_platform_root_cert_store()?;
let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider(
Arc::new(roots),
Arc::clone(provider),
)
.build()
.map_err(|e| e.to_string())?;
Ok(verifier)
}
}
}
/// Load the platform's trusted root certificates into a `RootCertStore` for
/// `WebPkiServerVerifier` (the `None` + X.509 CA-verification path). Falls back
/// to the aws-lc-rs built-in `webpki-roots` if the platform store is empty
/// (e.g. in a container with no system CA bundle).
#[cfg(feature = "quinn")]
fn load_platform_root_cert_store() -> Result<rustls::RootCertStore, String> {
let mut roots = rustls::RootCertStore::empty();
let result = rustls_native_certs::load_native_certs();
for err in &result.errors {
tracing::warn!(error = ?err, "failed to load a native root cert");
}
for cert in &result.certs {
roots
.add(cert.clone())
.map_err(|e| format!("failed to add native root cert: {e}"))?;
}
Ok(roots)
}
#[cfg(feature = "quinn")]
fn load_cert_chain(
path: &std::path::Path,
) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let mut reader = std::io::BufReader::new(bytes.as_slice());
rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())
}
#[cfg(feature = "quinn")]
fn load_private_key(
path: &std::path::Path,
) -> Result<rustls::pki_types::PrivateKeyDer<'static>, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let mut reader = std::io::BufReader::new(bytes.as_slice());
match rustls_pemfile::private_key(&mut reader) {
Ok(Some(key)) => Ok(key),
Ok(None) => Err("no private key found in file".to_string()),
Err(e) => Err(e.to_string()),
}
}
/// Client cert resolver that presents a single RFC 7250 raw public key (or
/// X.509 cert chain). For raw keys `only_raw_public_keys()` returns `true` so
/// rustls negotiates the RFC 7250 ClientCertificateType extension.
#[cfg(feature = "quinn")]
struct RawKeyClientCertResolver {
key: Arc<rustls::sign::CertifiedKey>,
raw_public_keys: bool,
}
#[cfg(feature = "quinn")]
impl RawKeyClientCertResolver {
fn new(key: Arc<rustls::sign::CertifiedKey>) -> Self {
let raw_public_keys = key.cert.len() == 1 && is_ed25519_spki(&key.cert[0]);
Self {
key,
raw_public_keys,
}
}
}
#[cfg(feature = "quinn")]
fn is_ed25519_spki(cert_der: &rustls::pki_types::CertificateDer<'_>) -> bool {
alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert_der.as_ref()).is_some()
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for RawKeyClientCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RawKeyClientCertResolver")
.field("raw_public_keys", &self.raw_public_keys)
.finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver {
fn resolve(
&self,
_root_hint_subjects: &[&[u8]],
_sigschemes: &[rustls::SignatureScheme],
) -> Option<Arc<rustls::sign::CertifiedKey>> {
Some(Arc::clone(&self.key))
}
fn only_raw_public_keys(&self) -> bool {
self.raw_public_keys
}
fn has_certs(&self) -> bool {
true
}
}
/// Client cert resolver that presents no client cert (the `tls_identity: None`
/// or `SelfSigned` path). The server gets nothing to fingerprint — the
/// `PeerEntry` fingerprint → `peer_id` resolution path is not activated for
/// this connection.
#[cfg(feature = "quinn")]
struct NoClientCertResolver;
#[cfg(feature = "quinn")]
impl std::fmt::Debug for NoClientCertResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NoClientCertResolver").finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::client::ResolvesClientCert for NoClientCertResolver {
fn resolve(
&self,
_root_hint_subjects: &[&[u8]],
_sigschemes: &[rustls::SignatureScheme],
) -> Option<Arc<rustls::sign::CertifiedKey>> {
None
}
fn has_certs(&self) -> bool {
false
}
}
/// `ServerCertVerifier` that pins a specific fingerprint (ADR-034 §3, the
/// known-peer path). For `ed25519:<hex>` remotes the raw Ed25519 pub key is
/// extracted from the presented cert and matched against the pinned
/// fingerprint; for `SHA256:<hex>` remotes the cert DER is hashed and matched
/// against the pinned fingerprint. No match → verification failure (the
/// connection is rejected). The fingerprint IS the trust anchor — there is no
/// CA verification and no name verification, only the fingerprint pin.
///
/// Handshake signatures are still verified (using the aws-lc-rs default
/// signature verification algorithms) so that a stolen-but-stale fingerprint
/// can't be replayed with a forged signature: the presenter must prove
/// possession of the private key corresponding to the pinned public key.
#[cfg(feature = "quinn")]
struct FingerprintPinVerifier {
fingerprint: String,
supported: rustls::crypto::WebPkiSupportedAlgorithms,
}
#[cfg(feature = "quinn")]
impl FingerprintPinVerifier {
fn new(fingerprint: String, supported: rustls::crypto::WebPkiSupportedAlgorithms) -> Self {
Self {
fingerprint,
supported,
}
}
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for FingerprintPinVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FingerprintPinVerifier")
.field("fingerprint", &self.fingerprint)
.finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::client::danger::ServerCertVerifier for FingerprintPinVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let presented = alknet_core::fingerprint::fingerprint_from_cert_der(end_entity.as_ref())
.ok_or(rustls::Error::General(
"fingerprint pin: failed to compute fingerprint from presented cert".to_string(),
))?;
if presented == self.fingerprint {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::General(format!(
"fingerprint pin mismatch: expected {} got {}",
self.fingerprint, presented
)))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
rustls::crypto::verify_tls13_signature_with_raw_key(
message,
&spki,
dss,
&self.supported,
)
} else {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported)
}
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() {
let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec());
rustls::crypto::verify_tls13_signature_with_raw_key(
message,
&spki,
dss,
&self.supported,
)
} else {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported)
}
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.supported.supported_schemes()
}
}
#[cfg(feature = "quinn")]
#[derive(Clone)]
struct Ed25519SigningKey {
key: alknet_core::config::Ed25519SecretKey,
}
#[cfg(feature = "quinn")]
impl Ed25519SigningKey {
fn new(key: alknet_core::config::Ed25519SecretKey) -> Self {
Self { key }
}
fn spki_public_key(&self) -> rustls::pki_types::SubjectPublicKeyInfoDer<'static> {
rustls::sign::public_key_to_spki(
&rustls::pki_types::alg_id::ED25519,
self.key.public().as_bytes(),
)
}
}
#[cfg(feature = "quinn")]
impl std::fmt::Debug for Ed25519SigningKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ed25519SigningKey").finish()
}
}
#[cfg(feature = "quinn")]
impl rustls::sign::SigningKey for Ed25519SigningKey {
fn choose_scheme(
&self,
offered: &[rustls::SignatureScheme],
) -> Option<Box<dyn rustls::sign::Signer>> {
if offered.contains(&rustls::SignatureScheme::ED25519) {
Some(Box::new(self.clone()))
} else {
None
}
}
fn algorithm(&self) -> rustls::SignatureAlgorithm {
rustls::SignatureAlgorithm::ED25519
}
fn public_key(&self) -> Option<rustls::pki_types::SubjectPublicKeyInfoDer<'_>> {
Some(self.spki_public_key())
}
}
#[cfg(feature = "quinn")]
impl rustls::sign::Signer for Ed25519SigningKey {
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, rustls::Error> {
Ok(self.key.sign(message).to_bytes().to_vec())
}
fn scheme(&self) -> rustls::SignatureScheme {
rustls::SignatureScheme::ED25519
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::connection::CallConnection;
use crate::protocol::wire::ResponseEnvelope;
use crate::registry::registration::{
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::Identity;
use alknet_core::types::{Capabilities, MockConnection};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Mutex as StdMutex;
struct StubConnection {
alpn: &'static [u8],
addr: Option<SocketAddr>,
closed: StdMutex<Option<(u32, String)>>,
}
impl MockConnection for StubConnection {
fn remote_alpn(&self) -> &[u8] {
self.alpn
}
fn remote_addr(&self) -> Option<SocketAddr> {
self.addr
}
fn close(&self, code: u32, reason: &str) {
*self.closed.lock().unwrap() = Some((code, reason.to_string()));
}
}
fn stub_connection() -> Connection {
Connection::from_mock(Arc::new(StubConnection {
alpn: b"alknet/call",
addr: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4321)),
closed: StdMutex::new(None),
}))
}
fn external_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
)
}
fn caps_inspect_handler() -> Handler {
make_handler(|_input, context| async move {
let has_google = context.capabilities.get("google").is_some();
ResponseEnvelope::ok(
context.request_id,
serde_json::json!({ "has_google_capability": has_google }),
)
})
}
struct NoopIdentityProvider;
impl alknet_core::auth::IdentityProvider for NoopIdentityProvider {
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, _token: &alknet_core::auth::AuthToken) -> Option<Identity> {
None
}
}
fn registry_with_caps() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("pub/run"),
HandlerKind::Once(caps_inspect_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "pub-key".to_string()),
))
.unwrap();
Arc::new(registry)
}
fn dispatcher(registry: &Arc<OperationRegistry>) -> Dispatcher {
Dispatcher::new(Arc::clone(registry), Arc::new(NoopIdentityProvider))
}
async fn dispatch(d: &Dispatcher, conn: &Arc<CallConnection>, op: &str) -> ResponseEnvelope {
d.dispatch_requested(
conn,
"req-test".to_string(),
serde_json::json!({ "operationId": op, "input": {} }),
)
.await
}
#[test]
fn call_credentials_builder_methods() {
let creds = CallCredentials::new().with_remote_identity(RemoteIdentity {
fingerprint: "SHA256:abc".to_string(),
});
assert_eq!(
creds.remote_identity.as_ref().unwrap().fingerprint,
"SHA256:abc"
);
assert!(creds.tls_identity.is_none());
assert!(creds.auth_token.is_none());
}
#[tokio::test]
async fn external_op_dispatches_and_populates_capabilities() {
let registry = registry_with_caps();
let d = dispatcher(&registry);
let conn = Arc::new(CallConnection::new(stub_connection()));
let response = dispatch(&d, &conn, "pub/run").await;
let out = response.result.expect("ok");
assert_eq!(
out["has_google_capability"],
serde_json::json!(true),
"an External op's call must populate capabilities for the handler"
);
}
#[tokio::test]
async fn unknown_op_returns_not_found() {
let registry = Arc::new(OperationRegistry::new());
let d = dispatcher(&registry);
let conn = Arc::new(CallConnection::new(stub_connection()));
let response = dispatch(&d, &conn, "no/such").await;
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
other => panic!("expected NOT_FOUND, got {other:?}"),
}
}
#[tokio::test]
async fn spawn_dispatch_returns_live_call_connection() {
let registry = registry_with_caps();
let client = CallClient::new(Arc::clone(&registry), Arc::new(NoopIdentityProvider));
let conn = client.spawn_dispatch(stub_connection());
assert_eq!(
conn.connection()
.expect("quic connection present")
.remote_alpn(),
b"alknet/call"
);
std::mem::drop(conn);
}
#[test]
fn call_client_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CallClient>();
assert_send_sync::<CallCredentials>();
assert_send_sync::<RemoteIdentity>();
}
#[cfg(feature = "quinn")]
fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec<u8> {
let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key);
spki.to_vec()
}
#[cfg(feature = "quinn")]
fn build_x509_cert_der() -> rustls::pki_types::CertificateDer<'static> {
let key_pair = rcgen::KeyPair::generate().expect("key gen");
let params = rcgen::CertificateParams::default();
let cert = params.self_signed(&key_pair).expect("self-signed cert");
cert.der().clone()
}
#[cfg(feature = "quinn")]
fn aws_lc_rs_provider() -> Arc<rustls::crypto::CryptoProvider> {
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
}
#[cfg(feature = "quinn")]
fn verify_pin(
verifier: &FingerprintPinVerifier,
cert_der: rustls::pki_types::CertificateDer<'_>,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
use rustls::client::danger::ServerCertVerifier;
let server_name: rustls::pki_types::ServerName<'static> =
"alknet".try_into().expect("server name");
verifier.verify_server_cert(
&cert_der,
&[],
&server_name,
&[],
rustls::pki_types::UnixTime::now(),
)
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_matches_correct_ed25519_fingerprint() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let fingerprint =
alknet_core::fingerprint::fingerprint_from_cert_der(&spki_der).expect("fingerprint");
let verifier = FingerprintPinVerifier::new(
fingerprint,
aws_lc_rs_provider().signature_verification_algorithms,
);
let cert = rustls::pki_types::CertificateDer::from(spki_der);
let result = verify_pin(&verifier, cert);
assert!(
result.is_ok(),
"FingerprintPinVerifier must accept a cert whose fingerprint matches the pin"
);
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_rejects_wrong_ed25519_fingerprint() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let other_sk = alknet_core::config::Ed25519SecretKey::generate();
let other_fp = format!("ed25519:{}", hex::encode(other_sk.public().to_bytes()));
let verifier = FingerprintPinVerifier::new(
other_fp,
aws_lc_rs_provider().signature_verification_algorithms,
);
let cert = rustls::pki_types::CertificateDer::from(spki_der);
let result = verify_pin(&verifier, cert);
assert!(
result.is_err(),
"FingerprintPinVerifier must reject a cert whose fingerprint does not match the pin"
);
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_matches_correct_sha256_fingerprint() {
let cert_der = build_x509_cert_der();
let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref())
.expect("fingerprint");
let verifier = FingerprintPinVerifier::new(
fingerprint,
aws_lc_rs_provider().signature_verification_algorithms,
);
let result = verify_pin(&verifier, cert_der);
assert!(
result.is_ok(),
"FingerprintPinVerifier must accept an X.509 cert whose SHA256 fingerprint matches"
);
}
#[cfg(feature = "quinn")]
#[test]
fn fingerprint_pin_verifier_rejects_wrong_sha256_fingerprint() {
let cert_der = build_x509_cert_der();
let verifier = FingerprintPinVerifier::new(
"SHA256:0000000000000000000000000000000000000000000000000000000000000000".to_string(),
aws_lc_rs_provider().signature_verification_algorithms,
);
let result = verify_pin(&verifier, cert_der);
assert!(
result.is_err(),
"FingerprintPinVerifier must reject an X.509 cert whose SHA256 does not match"
);
}
#[cfg(feature = "quinn")]
#[test]
fn select_server_verifier_returns_ca_verifier_for_none() {
let provider = aws_lc_rs_provider();
let remote_identity: Option<RemoteIdentity> = None;
let verifier = select_server_verifier(&provider, &remote_identity);
assert!(
verifier.is_ok(),
"select_server_verifier must succeed for None (CA path)"
);
let debug = format!("{:?}", verifier.unwrap());
assert!(
debug.contains("WebPkiServerVerifier"),
"None must select WebPkiServerVerifier (CA verification), got: {debug}"
);
}
#[cfg(feature = "quinn")]
#[test]
fn select_server_verifier_returns_fingerprint_pin_for_some() {
let provider = aws_lc_rs_provider();
let remote_identity = Some(RemoteIdentity {
fingerprint: "ed25519:abc".to_string(),
});
let verifier = select_server_verifier(&provider, &remote_identity);
assert!(
verifier.is_ok(),
"select_server_verifier must succeed for Some (fingerprint pin path)"
);
let debug = format!("{:?}", verifier.unwrap());
assert!(
debug.contains("FingerprintPinVerifier"),
"Some must select FingerprintPinVerifier, got: {debug}"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_client_auth_presents_ed25519_raw_key_without_error() {
let provider = aws_lc_rs_provider();
let sk = alknet_core::config::Ed25519SecretKey::generate();
let tls_identity = Some(alknet_core::config::TlsIdentity::RawKey(sk));
let resolver = build_client_auth(&provider, &tls_identity);
assert!(
resolver.is_ok(),
"build_client_auth must build a resolver for a RawKey identity"
);
let resolver = resolver.unwrap();
assert!(
resolver.only_raw_public_keys(),
"RawKey client auth resolver must present raw public keys (RFC 7250)"
);
assert!(
resolver.has_certs(),
"RawKey client auth resolver must report it has a cert to present"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_client_auth_none_resolves_to_no_client_cert() {
let provider = aws_lc_rs_provider();
let tls_identity: Option<alknet_core::config::TlsIdentity> = None;
let resolver = build_client_auth(&provider, &tls_identity)
.expect("build_client_auth must succeed for None");
assert!(
!resolver.has_certs(),
"NoClientCertResolver must report no certs (no client cert presented)"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_quinn_client_config_with_raw_key_identity_builds_without_error() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let credentials = CallCredentials::new()
.with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk))
.with_remote_identity(RemoteIdentity {
fingerprint: "ed25519:deadbeef".to_string(),
});
let config = build_quinn_client_config(&credentials, b"alknet/call");
assert!(
config.is_ok(),
"build_quinn_client_config must build with a RawKey identity + pinned fingerprint"
);
}
#[cfg(feature = "quinn")]
#[test]
fn build_quinn_client_config_with_no_remote_identity_builds_without_error() {
let sk = alknet_core::config::Ed25519SecretKey::generate();
let credentials =
CallCredentials::new().with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk));
let config = build_quinn_client_config(&credentials, b"alknet/call");
assert!(
config.is_ok(),
"build_quinn_client_config must build for the None + CA-verification path"
);
}
#[test]
fn remote_identity_none_is_load_bearing_not_defaulted() {
let creds = CallCredentials::new();
assert!(
creds.remote_identity.is_none(),
"CallCredentials::new() must keep remote_identity as None (the load-bearing \
public-X.509-endpoint state), not default it to a placeholder"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
//! Schema-only registration: produce a `HandlerRegistration` bundle with
//! `FromJsonSchema` provenance and no real handler. The caller fetches the
//! JSON Schema doc and passes it in; this adapter does no network I/O.
//!
//! See `docs/architecture/crates/call/client-and-adapters.md` (from_jsonschema
//! section) and ADR-017 §5.
use alknet_core::types::Capabilities;
use serde_json::Value;
use crate::client::{AdapterError, OperationAdapter};
use crate::protocol::wire::{CallError, ResponseEnvelope};
use crate::registry::context::OperationContext;
use crate::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use crate::registry::spec::OperationSpec;
/// Build a [`HandlerRegistration`] from a JSON Schema-described operation.
///
/// Schema-only: no real handler is attached — a placeholder returns a
/// `NOT_FOUND`-style error if ever invoked (schema-only ops are `Internal`,
/// so dispatch should never reach them; the placeholder fails loudly on
/// bugs). `provenance` is `FromJsonSchema`; `composition_authority` and
/// `scoped_env` are `None`; `capabilities` is empty.
pub fn from_jsonschema(spec: OperationSpec, _schema: Value) -> HandlerRegistration {
let handler = make_handler(|_input: Value, context: OperationContext| async move {
ResponseEnvelope::error(
context.request_id,
CallError::not_found("FromJsonSchema ops are schema-only and have no handler"),
)
});
HandlerRegistration::new(
spec,
HandlerKind::Once(handler),
OperationProvenance::FromJsonSchema,
None,
None,
Capabilities::new(),
)
}
/// A JSON-Schema-only [`OperationAdapter`].
///
/// Pure parse — no transport, no `.await` in `import()`. Returns
/// [`AdapterError::SchemaParse`] when the supplied schema is not a JSON
/// object.
pub struct FromJsonSchema {
spec: OperationSpec,
schema: Value,
}
impl FromJsonSchema {
pub fn new(spec: OperationSpec, schema: Value) -> Self {
Self { spec, schema }
}
}
#[async_trait::async_trait]
impl OperationAdapter for FromJsonSchema {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
if !self.schema.is_object() {
return Err(AdapterError::SchemaParse {
message: "schema must be a JSON object".into(),
});
}
Ok(vec![from_jsonschema(
self.spec.clone(),
self.schema.clone(),
)])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::from_jsonschema as from_jsonschema_fn;
use crate::registry::context::{AbortPolicy, ScopedPeerEnv};
use crate::registry::env::OperationEnv;
use crate::registry::spec::{AccessControl, OperationType, Visibility};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
struct NoopEnv;
#[async_trait::async_trait]
impl OperationEnv for NoopEnv {
async fn invoke_with_policy(
&self,
_namespace: &str,
_operation: &str,
_input: Value,
parent: &OperationContext,
_policy: AbortPolicy,
) -> ResponseEnvelope {
ResponseEnvelope::ok(parent.request_id.clone(), Value::Null)
}
}
fn test_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::Internal,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
)
}
fn test_context(request_id: &str) -> OperationContext {
OperationContext {
request_id: request_id.to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env: Arc::new(NoopEnv),
abort_policy: AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: true,
}
}
#[test]
fn from_jsonschema_bundle_shape() {
let bundle = from_jsonschema_fn::from_jsonschema(test_spec("ns/op"), serde_json::json!({}));
assert_eq!(bundle.spec.name, "ns/op");
assert_eq!(bundle.provenance, OperationProvenance::FromJsonSchema);
assert!(bundle.composition_authority.is_none());
assert!(bundle.scoped_env.is_none());
}
#[tokio::test]
async fn placeholder_handler_returns_error_when_invoked() {
let bundle = from_jsonschema_fn::from_jsonschema(test_spec("ns/op"), serde_json::json!({}));
let ctx = test_context("req-1");
let response = match &bundle.handler {
HandlerKind::Once(h) => h(serde_json::json!({}), ctx).await,
_ => panic!("expected Once handler"),
};
match response.result {
Err(e) => {
assert_eq!(e.code, "NOT_FOUND");
assert!(e.message.contains("FromJsonSchema"));
}
other => panic!("expected NOT_FOUND, got {other:?}"),
}
}
#[tokio::test]
async fn import_returns_ok_with_one_bundle() {
let adapter =
FromJsonSchema::new(test_spec("ns/op"), serde_json::json!({"type": "object"}));
let bundles = match adapter.import().await {
Ok(b) => b,
Err(e) => panic!("expected Ok, got Err: {e}"),
};
assert_eq!(bundles.len(), 1);
assert_eq!(bundles[0].provenance, OperationProvenance::FromJsonSchema);
}
#[tokio::test]
async fn import_non_object_schema_returns_schema_parse() {
let adapter = FromJsonSchema::new(test_spec("ns/op"), serde_json::json!(42));
match adapter.import().await {
Ok(_) => panic!("expected Err"),
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("JSON object"));
}
Err(other) => panic!("expected SchemaParse, got {other}"),
}
}
}

View File

@@ -0,0 +1,109 @@
//! Client adapters: turn external operation sources (JSON Schema, OpenAPI,
//! MCP, remote `from_call` peers) into `HandlerRegistration` bundles.
//!
//! See `docs/architecture/crates/call/client-and-adapters.md` for the
//! OperationAdapter trait and the Adapter Location Map, and
//! `docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md`
//! §5 for the trait contract.
mod call_client;
mod from_call;
mod from_jsonschema;
pub use call_client::{CallClient, CallCredentials, ClientError, RemoteIdentity};
pub use from_call::{from_call, FromCallConfig};
pub use from_jsonschema::{from_jsonschema, FromJsonSchema};
use crate::registry::registration::HandlerRegistration;
/// Errors produced by [`OperationAdapter::import`].
///
/// The variant set is the v1 default (two-way-door remainder, OQ-26);
/// `#[non_exhaustive]` lets downstream adapters (e.g. `alknet-http`'s
/// `from_openapi`/`from_mcp`) extend without breaking match arms. All
/// payloads are string messages — kept simple and `Send + Sync` by
/// construction.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AdapterError {
/// `from_call` remote unreachable / `services/list` failed.
#[error("discovery failed: {message}")]
DiscoveryFailed { message: String },
/// `from_openapi` / `from_jsonschema` couldn't parse the spec.
#[error("schema parse error: {message}")]
SchemaParse { message: String },
/// Underlying transport error (QUIC for `from_call`, HTTP for adapters).
#[error("transport error: {message}")]
Transport { message: String },
/// HTTP 401 for `from_openapi`/`from_mcp`, auth rejected for `from_call`.
#[error("unauthorized: {message}")]
Unauthorized { message: String },
/// Same-peer namespace collision in `from_call` (ADR-029 §5; OQ-26).
/// Cross-peer collision dissolves (same name on different peers lives in
/// separate sub-overlays); same-peer collision stays an error — a peer
/// shouldn't expose two ops with the same name.
#[error("same-peer collision: {message}")]
SamePeerCollision { message: String },
}
/// Import a set of operations as `HandlerRegistration` bundles.
///
/// Async because `from_call` requires async discovery (`services/list` +
/// `services/schema` over a QUIC connection); sync adapters (e.g.
/// `from_jsonschema`, `from_openapi` reading a static spec) trivially satisfy
/// an async trait — their `import()` bodies contain no `.await` points.
///
/// See ADR-017 §5 (`docs/architecture/decisions/017-call-protocol-client-and-adapter-contract.md`)
/// and `docs/architecture/crates/call/client-and-adapters.md`.
#[async_trait::async_trait]
pub trait OperationAdapter: Send + Sync {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError>;
}
#[cfg(test)]
mod tests {
use super::*;
struct OkAdapter;
#[async_trait::async_trait]
impl OperationAdapter for OkAdapter {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
Ok(vec![])
}
}
struct ErrAdapter;
#[async_trait::async_trait]
impl OperationAdapter for ErrAdapter {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
Err(AdapterError::SchemaParse {
message: "x".into(),
})
}
}
#[tokio::test]
async fn ok_adapter_imports_empty() {
let adapter = OkAdapter;
match adapter.import().await {
Ok(bundles) => assert!(bundles.is_empty()),
Err(e) => panic!("expected Ok, got Err: {e}"),
}
}
#[tokio::test]
async fn err_adapter_returns_schema_parse() {
let adapter = ErrAdapter;
match adapter.import().await {
Ok(_) => panic!("expected Err"),
Err(AdapterError::SchemaParse { message }) => assert_eq!(message, "x"),
Err(other) => panic!("expected SchemaParse, got {other}"),
}
}
}

View File

@@ -0,0 +1,11 @@
//! alknet-call: Structured RPC over QUIC — operations, streaming, service discovery.
//!
//! Implements [`alknet_core::types::ProtocolHandler`] on ALPN `alknet/call`.
//!
//! The crate has two subsystems:
//! - [`registry`] — operation specs, context, dispatch, and the operation registry.
//! - [`protocol`] — wire format, streams, and the call adapter.
pub mod client;
pub mod protocol;
pub mod registry;

View File

@@ -0,0 +1,393 @@
//! Abort cascade logic for nested calls (ADR-016).
//!
//! When `call.aborted` arrives for a parent request, the protocol cascades
//! the abort to all non-terminal descendants in the call tree. The default
//! policy is `abort-dependents`; `continue-running` is an opt-in for
//! long-running work that should survive a parent's abort.
//!
//! The call tree is indexed by `parent_request_id` in the
//! `PendingRequestMap`. The root request has `parent_request_id: None`;
//! each composed call has `parent_request_id: Some(parent.request_id)`.
//! Composed child request IDs are internal — they appear in the map for
//! abort-cascade indexing but are not sent as `call.requested` to any
//! peer. The client only sees `call.aborted` for the root ID it sent; the
//! server cascades internally to descendants.
use super::pending::PendingRequestMap;
use crate::registry::context::AbortPolicy;
pub struct AbortCascade<'a> {
pending: &'a mut PendingRequestMap,
}
impl<'a> AbortCascade<'a> {
pub fn new(pending: &'a mut PendingRequestMap) -> Self {
Self { pending }
}
/// Cascade an abort from the given request ID to all non-terminal
/// descendants in the call tree. Returns the list of descendant
/// request IDs that were aborted (for logging/auditing), sorted for
/// determinism. The root request itself is not touched by this
/// method — the caller is responsible for aborting the root (the
/// trigger of the cascade).
///
/// Under `AbortDependents` (default): all descendants are aborted,
/// regardless of whether they have started.
///
/// Under `ContinueRunning`: only descendants that have not started
/// are aborted; started descendants continue to completion. No new
/// descendants start (the parent is gone). This is the conservative
/// approximation noted in ADR-016: a descendant is "started" if
/// `PendingEntry::started` is true (the handler has begun
/// executing). A `call.aborted` for an unknown request ID is
/// silently discarded — `cascade_abort` on an unknown root returns
/// an empty list and removes nothing.
pub fn cascade_abort(&mut self, root_request_id: &str, policy: AbortPolicy) -> Vec<String> {
if !self.pending.contains(root_request_id) {
return Vec::new();
}
let descendants = self.find_descendants(root_request_id);
let mut aborted = Vec::new();
match policy {
AbortPolicy::AbortDependents => {
for id in &descendants {
if self.pending.handle_aborted(id) {
aborted.push(id.clone());
}
}
}
AbortPolicy::ContinueRunning => {
for id in &descendants {
let started = self.pending.is_started(id).unwrap_or(false);
if !started && self.pending.handle_aborted(id) {
aborted.push(id.clone());
}
}
}
}
aborted.sort();
aborted
}
/// Find all descendants of a request ID in the call tree by walking
/// the `parent_request_id` index. Returns descendants in
/// breadth-first order with each level's children sorted for
/// determinism. The root itself is not included in the result.
fn find_descendants(&self, parent_id: &str) -> Vec<String> {
let mut descendants = Vec::new();
let mut frontier: Vec<String> = vec![parent_id.to_string()];
while let Some(current) = frontier.pop() {
let mut children: Vec<String> = self
.pending
.request_ids()
.into_iter()
.filter(|id| {
self.pending
.parent_of(id)
.flatten()
.is_some_and(|p| p == current)
})
.collect();
children.sort();
for child in children {
descendants.push(child.clone());
frontier.push(child);
}
}
descendants
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::wire::CallError;
use std::time::{Duration, Instant};
fn register_call(map: &mut PendingRequestMap, id: &str, parent: Option<&str>) {
map.register_call(
id.to_string(),
Instant::now() + Duration::from_secs(30),
parent.map(|p| p.to_string()),
);
}
fn register_subscribe(map: &mut PendingRequestMap, id: &str, parent: Option<&str>) {
map.register_subscribe(id.to_string(), None, parent.map(|p| p.to_string()));
}
#[test]
fn cascade_abort_unknown_root_returns_empty_and_is_noop() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("does-not-exist", AbortPolicy::AbortDependents);
assert!(aborted.is_empty());
assert!(cascade.pending.contains("r1"));
}
#[test]
fn cascade_abort_abort_dependents_aborts_all_descendants() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
register_call(&mut map, "r1-b", Some("r1"));
register_call(&mut map, "r1-a-1", Some("r1-a"));
register_call(&mut map, "r1-a-2", Some("r1-a"));
register_call(&mut map, "r1-b-1", Some("r1-b"));
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents);
assert_eq!(
aborted,
vec![
"r1-a".to_string(),
"r1-a-1".to_string(),
"r1-a-2".to_string(),
"r1-b".to_string(),
"r1-b-1".to_string(),
]
);
assert!(cascade.pending.contains("r1"));
assert!(!cascade.pending.contains("r1-a"));
assert!(!cascade.pending.contains("r1-b"));
assert!(!cascade.pending.contains("r1-a-1"));
assert!(!cascade.pending.contains("r1-a-2"));
assert!(!cascade.pending.contains("r1-b-1"));
}
#[test]
fn cascade_abort_continue_running_aborts_only_unstarted_descendants() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
register_call(&mut map, "r1-b", Some("r1"));
register_call(&mut map, "r1-a-1", Some("r1-a"));
map.mark_started("r1-a");
// r1-b and r1-a-1 are unstarted
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning);
assert_eq!(aborted, vec!["r1-a-1".to_string(), "r1-b".to_string()]);
assert!(cascade.pending.contains("r1"));
assert!(cascade.pending.contains("r1-a"));
assert!(!cascade.pending.contains("r1-b"));
assert!(!cascade.pending.contains("r1-a-1"));
}
#[test]
fn cascade_abort_continue_running_aborts_all_when_none_started() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
register_call(&mut map, "r1-b", Some("r1"));
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning);
assert_eq!(aborted, vec!["r1-a".to_string(), "r1-b".to_string()]);
assert!(!cascade.pending.contains("r1-a"));
assert!(!cascade.pending.contains("r1-b"));
}
#[test]
fn cascade_abort_depth_three_aborts_all_descendants() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "root", None);
register_call(&mut map, "root-a", Some("root"));
register_call(&mut map, "root-b", Some("root"));
register_call(&mut map, "root-a-1", Some("root-a"));
register_call(&mut map, "root-a-2", Some("root-a"));
register_call(&mut map, "root-a-1-x", Some("root-a-1"));
register_call(&mut map, "root-a-1-y", Some("root-a-1"));
register_call(&mut map, "root-b-1", Some("root-b"));
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("root", AbortPolicy::AbortDependents);
assert_eq!(
aborted,
vec![
"root-a".to_string(),
"root-a-1".to_string(),
"root-a-1-x".to_string(),
"root-a-1-y".to_string(),
"root-a-2".to_string(),
"root-b".to_string(),
"root-b-1".to_string(),
]
);
assert!(cascade.pending.contains("root"));
assert_eq!(cascade.pending.len(), 1);
}
#[test]
fn cascade_abort_root_with_no_descendants_returns_empty() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "lonely", None);
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("lonely", AbortPolicy::AbortDependents);
assert!(aborted.is_empty());
assert!(cascade.pending.contains("lonely"));
}
#[test]
fn cascade_abort_only_aborts_descendants_not_siblings() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r2", None);
register_call(&mut map, "r1-a", Some("r1"));
register_call(&mut map, "r2-a", Some("r2"));
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents);
assert_eq!(aborted, vec!["r1-a".to_string()]);
assert!(cascade.pending.contains("r1"));
assert!(cascade.pending.contains("r2"));
assert!(cascade.pending.contains("r2-a"));
assert!(!cascade.pending.contains("r1-a"));
}
#[test]
fn cascade_abort_handles_mixed_call_and_subscribe_entries() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_subscribe(&mut map, "r1-sub", Some("r1"));
register_call(&mut map, "r1-sub-child", Some("r1-sub"));
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents);
assert_eq!(
aborted,
vec!["r1-sub".to_string(), "r1-sub-child".to_string(),]
);
assert!(cascade.pending.contains("r1"));
assert_eq!(cascade.pending.len(), 1);
}
#[test]
fn cascade_abort_continue_running_with_started_descendant_keeps_its_unstarted_children() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
register_call(&mut map, "r1-a-1", Some("r1-a"));
map.mark_started("r1-a");
// r1-a is started and continues; r1-a-1 is unstarted.
// Under ContinueRunning, r1-a-1 is aborted (conservative: still pending).
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning);
assert_eq!(aborted, vec!["r1-a-1".to_string()]);
assert!(cascade.pending.contains("r1-a"));
assert!(!cascade.pending.contains("r1-a-1"));
}
#[test]
fn cascade_abort_abort_dependents_aborts_started_descendants_too() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
register_call(&mut map, "r1-b", Some("r1"));
map.mark_started("r1-a");
map.mark_started("r1-b");
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents);
assert_eq!(aborted, vec!["r1-a".to_string(), "r1-b".to_string()]);
assert!(!cascade.pending.contains("r1-a"));
assert!(!cascade.pending.contains("r1-b"));
}
#[test]
fn find_descendants_does_not_include_root() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
let cascade = AbortCascade::new(&mut map);
let descendants = cascade.find_descendants("r1");
assert_eq!(descendants, vec!["r1-a".to_string()]);
assert!(!descendants.contains(&"r1".to_string()));
}
#[test]
fn cascade_abort_default_policy_is_abort_dependents() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
map.mark_started("r1-a");
let mut cascade = AbortCascade::new(&mut map);
let aborted_default = cascade.cascade_abort("r1", AbortPolicy::default());
assert_eq!(aborted_default, vec!["r1-a".to_string()]);
}
#[test]
fn cascade_abort_does_not_remove_root() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
let mut cascade = AbortCascade::new(&mut map);
let _ = cascade.cascade_abort("r1", AbortPolicy::AbortDependents);
assert!(cascade.pending.contains("r1"));
}
#[test]
fn cascade_abort_returns_sorted_descendants_for_determinism() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-z", Some("r1"));
register_call(&mut map, "r1-a", Some("r1"));
register_call(&mut map, "r1-m", Some("r1"));
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::AbortDependents);
assert_eq!(
aborted,
vec!["r1-a".to_string(), "r1-m".to_string(), "r1-z".to_string(),]
);
}
#[test]
fn unknown_request_id_silently_discarded_no_panic() {
let mut map = PendingRequestMap::new();
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("totally-unknown", AbortPolicy::AbortDependents);
assert!(aborted.is_empty());
}
#[test]
fn cascade_abort_continue_running_started_descendant_survives() {
let mut map = PendingRequestMap::new();
register_call(&mut map, "r1", None);
register_call(&mut map, "r1-a", Some("r1"));
map.mark_started("r1-a");
let mut cascade = AbortCascade::new(&mut map);
let aborted = cascade.cascade_abort("r1", AbortPolicy::ContinueRunning);
assert!(aborted.is_empty());
assert!(cascade.pending.contains("r1-a"));
}
#[test]
fn cascade_abort_handles_call_error_unused() {
let _ = CallError::internal("unused");
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
//! Call protocol: wire format, streams, and the call adapter.
//!
//! Implements `ProtocolHandler` for ALPN `alknet/call` on top of the
//! operation registry. See `docs/architecture/crates/call/call-protocol.md`
//! for the full specification.
pub mod abort;
pub mod adapter;
pub mod connection;
pub mod dispatch;
pub mod pending;
pub mod wire;

View File

@@ -0,0 +1,584 @@
use std::collections::HashMap;
use std::time::Instant;
use serde_json::Value;
use tokio::sync::{mpsc, oneshot};
use crate::protocol::wire::CallError;
const SUBSCRIBE_CHANNEL_CAPACITY: usize = 32;
pub struct PendingRequestMap {
pending: HashMap<String, PendingEntry>,
}
pub(crate) enum PendingEntry {
Call {
tx: oneshot::Sender<Result<Value, CallError>>,
timeout: Instant,
parent_request_id: Option<String>,
started: bool,
},
Subscribe {
tx: mpsc::Sender<Result<Value, CallError>>,
timeout: Option<Instant>,
parent_request_id: Option<String>,
started: bool,
},
}
impl PendingEntry {
pub(crate) fn parent_request_id(&self) -> Option<&str> {
match self {
PendingEntry::Call {
parent_request_id, ..
} => parent_request_id.as_deref(),
PendingEntry::Subscribe {
parent_request_id, ..
} => parent_request_id.as_deref(),
}
}
pub(crate) fn started(&self) -> bool {
match self {
PendingEntry::Call { started, .. } => *started,
PendingEntry::Subscribe { started, .. } => *started,
}
}
}
impl PendingRequestMap {
pub fn new() -> Self {
Self {
pending: HashMap::new(),
}
}
pub fn register_call(
&mut self,
request_id: String,
timeout: Instant,
parent_request_id: Option<String>,
) -> oneshot::Receiver<Result<Value, CallError>> {
let (tx, rx) = oneshot::channel();
self.pending.insert(
request_id,
PendingEntry::Call {
tx,
timeout,
parent_request_id,
started: false,
},
);
rx
}
pub fn register_subscribe(
&mut self,
request_id: String,
timeout: Option<Instant>,
parent_request_id: Option<String>,
) -> mpsc::Receiver<Result<Value, CallError>> {
let (tx, rx) = mpsc::channel(SUBSCRIBE_CHANNEL_CAPACITY);
self.pending.insert(
request_id,
PendingEntry::Subscribe {
tx,
timeout,
parent_request_id,
started: false,
},
);
rx
}
pub fn mark_started(&mut self, request_id: &str) -> bool {
let Some(entry) = self.pending.get_mut(request_id) else {
return false;
};
match entry {
PendingEntry::Call { started, .. } => *started = true,
PendingEntry::Subscribe { started, .. } => *started = true,
}
true
}
pub fn handle_responded(&mut self, request_id: &str, output: Value) -> bool {
let Some(entry) = self.pending.remove(request_id) else {
return false;
};
match entry {
PendingEntry::Call { tx, .. } => {
let _ = tx.send(Ok(output));
true
}
PendingEntry::Subscribe {
tx,
timeout,
parent_request_id,
started,
} => {
let send_result = tx.try_send(Ok(output));
match send_result {
Ok(()) => {
self.pending.insert(
request_id.to_string(),
PendingEntry::Subscribe {
tx,
timeout,
parent_request_id,
started,
},
);
true
}
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!(
request_id,
"subscribe channel full; dropping entry and closing subscription"
);
true
}
Err(mpsc::error::TrySendError::Closed(_)) => true,
}
}
}
}
pub fn handle_completed(&mut self, request_id: &str) -> bool {
self.pending.remove(request_id).is_some()
}
pub fn handle_aborted(&mut self, request_id: &str) -> bool {
self.pending.remove(request_id).is_some()
}
pub fn handle_error(&mut self, request_id: &str, error: CallError) -> bool {
let Some(entry) = self.pending.remove(request_id) else {
return false;
};
match entry {
PendingEntry::Call { tx, .. } => {
let _ = tx.send(Err(error));
true
}
PendingEntry::Subscribe { tx, .. } => {
let _ = tx.try_send(Err(error));
true
}
}
}
pub fn evict_expired(&mut self) -> Vec<String> {
let now = Instant::now();
let mut evicted = Vec::new();
let mut to_remove: Vec<String> = Vec::new();
for (id, entry) in self.pending.iter() {
let expired = match entry {
PendingEntry::Call { timeout, .. } => *timeout <= now,
PendingEntry::Subscribe {
timeout: Some(t), ..
} => *t <= now,
PendingEntry::Subscribe { timeout: None, .. } => false,
};
if expired {
to_remove.push(id.clone());
}
}
for id in to_remove {
let Some(entry) = self.pending.remove(&id) else {
continue;
};
let timeout_err = CallError::timeout("request timed out");
match entry {
PendingEntry::Call { tx, .. } => {
let _ = tx.send(Err(timeout_err));
}
PendingEntry::Subscribe { tx, .. } => {
let _ = tx.try_send(Err(timeout_err));
}
}
evicted.push(id);
}
evicted
}
pub fn fail_all(&mut self, error: CallError) -> Vec<String> {
let ids: Vec<String> = self.pending.keys().cloned().collect();
for id in &ids {
if let Some(entry) = self.pending.remove(id) {
match entry {
PendingEntry::Call { tx, .. } => {
let _ = tx.send(Err(error.clone()));
}
PendingEntry::Subscribe { tx, .. } => {
let _ = tx.try_send(Err(error.clone()));
}
}
}
}
ids
}
pub fn contains(&self, request_id: &str) -> bool {
self.pending.contains_key(request_id)
}
pub(crate) fn parent_of(&self, request_id: &str) -> Option<Option<String>> {
self.pending
.get(request_id)
.map(|e| e.parent_request_id().map(|s| s.to_string()))
}
pub(crate) fn is_started(&self, request_id: &str) -> Option<bool> {
self.pending.get(request_id).map(|e| e.started())
}
pub(crate) fn request_ids(&self) -> Vec<String> {
self.pending.keys().cloned().collect()
}
pub fn len(&self) -> usize {
self.pending.len()
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
}
impl Default for PendingRequestMap {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::time::Duration;
use tokio::time::timeout;
fn timeout_error() -> CallError {
CallError::timeout("request timed out")
}
fn internal_error(message: &str) -> CallError {
CallError::internal(message)
}
#[tokio::test]
async fn register_call_then_handle_responded_resolves_oneshot() {
let mut map = PendingRequestMap::new();
let rx = map.register_call(
"req-1".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
assert!(map.contains("req-1"));
assert_eq!(map.len(), 1);
assert!(map.handle_responded("req-1", json!(42)));
let result = timeout(Duration::from_millis(100), rx).await;
match result {
Ok(Ok(Ok(value))) => assert_eq!(value, json!(42)),
other => panic!("expected Ok(42), got {other:?}"),
}
assert!(!map.contains("req-1"));
assert_eq!(map.len(), 0);
}
#[tokio::test]
async fn register_subscribe_then_handle_responded_pushes_to_channel() {
let mut map = PendingRequestMap::new();
let mut rx = map.register_subscribe("sub-1".to_string(), None, None);
assert!(map.handle_responded("sub-1", json!("first")));
assert!(map.handle_responded("sub-1", json!("second")));
assert!(map.contains("sub-1"));
let first = timeout(Duration::from_millis(100), rx.recv()).await;
let second = timeout(Duration::from_millis(100), rx.recv()).await;
match (first, second) {
(Ok(Some(Ok(a))), Ok(Some(Ok(b)))) => {
assert_eq!(a, json!("first"));
assert_eq!(b, json!("second"));
}
other => panic!("expected two Ok values, got {other:?}"),
}
}
#[tokio::test]
async fn subscribe_handle_completed_closes_channel_and_deletes_entry() {
let mut map = PendingRequestMap::new();
let mut rx = map.register_subscribe("sub-2".to_string(), None, None);
assert!(map.handle_responded("sub-2", json!("a")));
assert!(map.handle_completed("sub-2"));
assert!(!map.contains("sub-2"));
let _ = timeout(Duration::from_millis(100), rx.recv()).await;
let after_close = timeout(Duration::from_millis(100), rx.recv()).await;
match after_close {
Ok(None) => {}
other => panic!("expected channel closed (None), got {other:?}"),
}
}
#[tokio::test]
async fn expired_call_is_evicted_with_timeout_error() {
let mut map = PendingRequestMap::new();
let rx = map.register_call(
"req-2".to_string(),
Instant::now() - Duration::from_millis(1),
None,
);
let evicted = map.evict_expired();
assert_eq!(evicted, vec!["req-2".to_string()]);
assert!(!map.contains("req-2"));
let result = timeout(Duration::from_millis(100), rx).await;
match result {
Ok(Ok(Err(e))) => {
assert_eq!(e.code, "TIMEOUT");
assert!(e.retryable);
}
other => panic!("expected Err(TIMEOUT), got {other:?}"),
}
}
#[tokio::test]
async fn expired_subscribe_is_evicted_with_timeout_error() {
let mut map = PendingRequestMap::new();
let mut rx = map.register_subscribe(
"sub-3".to_string(),
Some(Instant::now() - Duration::from_millis(1)),
None,
);
let evicted = map.evict_expired();
assert_eq!(evicted, vec!["sub-3".to_string()]);
let result = timeout(Duration::from_millis(100), rx.recv()).await;
match result {
Ok(Some(Err(e))) => {
assert_eq!(e.code, "TIMEOUT");
assert!(e.retryable);
}
other => panic!("expected Err(TIMEOUT), got {other:?}"),
}
}
#[tokio::test]
async fn unbounded_subscribe_is_not_evicted() {
let mut map = PendingRequestMap::new();
let _rx = map.register_subscribe("sub-4".to_string(), None, None);
let evicted = map.evict_expired();
assert!(evicted.is_empty());
assert!(map.contains("sub-4"));
}
#[tokio::test]
async fn fail_all_resolves_all_pending_with_internal_error() {
let mut map = PendingRequestMap::new();
let rx_call = map.register_call(
"c-1".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
let mut rx_sub = map.register_subscribe(
"s-1".to_string(),
Some(Instant::now() + Duration::from_secs(30)),
None,
);
let failed = map.fail_all(internal_error("connection closed"));
assert_eq!(failed.len(), 2);
assert!(failed.contains(&"c-1".to_string()));
assert!(failed.contains(&"s-1".to_string()));
assert!(map.is_empty());
let call_result = timeout(Duration::from_millis(100), rx_call).await;
match call_result {
Ok(Ok(Err(e))) => {
assert_eq!(e.code, "INTERNAL");
assert_eq!(e.message, "connection closed");
}
other => panic!("expected Err(INTERNAL), got {other:?}"),
}
let sub_result = timeout(Duration::from_millis(100), rx_sub.recv()).await;
match sub_result {
Ok(Some(Err(e))) => {
assert_eq!(e.code, "INTERNAL");
assert_eq!(e.message, "connection closed");
}
other => panic!("expected Err(INTERNAL), got {other:?}"),
}
}
#[tokio::test]
async fn handle_responded_unknown_request_id_returns_false() {
let mut map = PendingRequestMap::new();
assert!(!map.handle_responded("nonexistent", json!(1)));
assert_eq!(map.len(), 0);
}
#[tokio::test]
async fn handle_completed_unknown_request_id_returns_false() {
let mut map = PendingRequestMap::new();
assert!(!map.handle_completed("nonexistent"));
}
#[tokio::test]
async fn handle_aborted_unknown_request_id_returns_false() {
let mut map = PendingRequestMap::new();
assert!(!map.handle_aborted("nonexistent"));
}
#[tokio::test]
async fn handle_error_unknown_request_id_returns_false() {
let mut map = PendingRequestMap::new();
assert!(!map.handle_error("nonexistent", internal_error("x")));
}
#[tokio::test]
async fn handle_aborted_cancels_pending_call() {
let mut map = PendingRequestMap::new();
let rx = map.register_call(
"req-3".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
assert!(map.handle_aborted("req-3"));
assert!(!map.contains("req-3"));
let result = timeout(Duration::from_millis(100), rx).await;
match result {
Ok(Err(_)) => {}
other => panic!("expected sender dropped (Err), got {other:?}"),
}
}
#[tokio::test]
async fn handle_error_resolves_call_with_error() {
let mut map = PendingRequestMap::new();
let rx = map.register_call(
"req-4".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
let err = CallError::new("FILE_NOT_FOUND", "missing", false);
assert!(map.handle_error("req-4", err.clone()));
assert!(!map.contains("req-4"));
let result = timeout(Duration::from_millis(100), rx).await;
match result {
Ok(Ok(Err(e))) => {
assert_eq!(e.code, "FILE_NOT_FOUND");
assert!(!e.retryable);
}
other => panic!("expected Err(FILE_NOT_FOUND), got {other:?}"),
}
}
#[tokio::test]
async fn handle_error_pushes_to_subscribe_channel() {
let mut map = PendingRequestMap::new();
let mut rx = map.register_subscribe("sub-5".to_string(), None, None);
let err = CallError::new("RATE_LIMITED", "too fast", true);
assert!(map.handle_error("sub-5", err.clone()));
assert!(!map.contains("sub-5"));
let result = timeout(Duration::from_millis(100), rx.recv()).await;
match result {
Ok(Some(Err(e))) => {
assert_eq!(e.code, "RATE_LIMITED");
assert!(e.retryable);
}
other => panic!("expected Err(RATE_LIMITED), got {other:?}"),
}
}
#[tokio::test]
async fn correlation_by_id_not_by_stream() {
let mut map = PendingRequestMap::new();
let rx = map.register_call(
"req-stream-3".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
assert!(map.handle_responded("req-stream-3", json!("response-from-stream-7")));
let result = timeout(Duration::from_millis(100), rx).await;
match result {
Ok(Ok(Ok(value))) => assert_eq!(value, json!("response-from-stream-7")),
other => panic!("expected Ok, got {other:?}"),
}
}
#[tokio::test]
async fn register_call_overwrites_existing_entry() {
let mut map = PendingRequestMap::new();
let _rx_old = map.register_call(
"req-5".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
let rx_new = map.register_call(
"req-5".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
assert_eq!(map.len(), 1);
assert!(map.handle_responded("req-5", json!("new")));
let result = timeout(Duration::from_millis(100), rx_new).await;
match result {
Ok(Ok(Ok(value))) => assert_eq!(value, json!("new")),
other => panic!("expected Ok from new receiver, got {other:?}"),
}
}
#[tokio::test]
async fn evict_expired_skips_non_expired_entries() {
let mut map = PendingRequestMap::new();
let _rx_expired = map.register_call(
"expired".to_string(),
Instant::now() - Duration::from_millis(1),
None,
);
let _rx_alive = map.register_call(
"alive".to_string(),
Instant::now() + Duration::from_secs(60),
None,
);
let evicted = map.evict_expired();
assert_eq!(evicted, vec!["expired".to_string()]);
assert!(map.contains("alive"));
assert!(!map.contains("expired"));
}
#[tokio::test]
async fn default_is_empty_map() {
let map = PendingRequestMap::default();
assert!(map.is_empty());
assert_eq!(map.len(), 0);
}
#[tokio::test]
async fn timeout_error_helper() {
let err = timeout_error();
assert_eq!(err.code, "TIMEOUT");
assert!(err.retryable);
}
}

View File

@@ -0,0 +1,548 @@
//! Wire format: `EventEnvelope`, `ResponseEnvelope`, `CallError`, and
//! length-prefixed JSON framing.
//!
//! See `docs/architecture/crates/call/call-protocol.md` for the full
//! specification.
use std::io;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
pub const EVENT_REQUESTED: &str = "call.requested";
pub const EVENT_RESPONDED: &str = "call.responded";
pub const EVENT_COMPLETED: &str = "call.completed";
pub const EVENT_ABORTED: &str = "call.aborted";
pub const EVENT_ERROR: &str = "call.error";
const LENGTH_PREFIX_BYTES: usize = 4;
const MAX_FRAME_SIZE: u32 = 64 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventEnvelope {
#[serde(rename = "type")]
pub r#type: String,
pub id: String,
pub payload: Value,
}
impl EventEnvelope {
pub fn new(event_type: impl Into<String>, id: impl Into<String>, payload: Value) -> Self {
Self {
r#type: event_type.into(),
id: id.into(),
payload,
}
}
pub fn requested(id: impl Into<String>, payload: Value) -> Self {
Self::new(EVENT_REQUESTED, id, payload)
}
pub fn responded(id: impl Into<String>, output: Value) -> Self {
Self::new(EVENT_RESPONDED, id, serde_json::json!({ "output": output }))
}
pub fn completed(id: impl Into<String>) -> Self {
Self::new(EVENT_COMPLETED, id, serde_json::json!({}))
}
pub fn aborted(id: impl Into<String>) -> Self {
Self::new(EVENT_ABORTED, id, serde_json::json!({}))
}
pub fn error(id: impl Into<String>, error: &CallError) -> Self {
let payload = serde_json::to_value(error).unwrap_or(Value::Null);
Self::new(EVENT_ERROR, id, payload)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CallError {
pub code: String,
pub message: String,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl CallError {
pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
Self {
code: code.into(),
message: message.into(),
retryable,
details: None,
}
}
pub fn with_details(mut self, details: Value) -> Self {
self.details = Some(details);
self
}
pub fn not_found(op_name: &str) -> Self {
Self::new(
"NOT_FOUND",
format!("operation not found: {op_name}"),
false,
)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new("FORBIDDEN", message, false)
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new("INVALID_INPUT", message, false)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new("INTERNAL", message, false)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new("TIMEOUT", message, true)
}
pub fn invalid_operation_type(message: impl Into<String>) -> Self {
Self::new("INVALID_OPERATION_TYPE", message, false)
}
}
impl Eq for CallError {}
#[derive(Debug, Clone, PartialEq)]
pub struct ResponseEnvelope {
pub request_id: String,
pub result: Result<Value, CallError>,
}
impl ResponseEnvelope {
pub fn ok(request_id: impl Into<String>, output: Value) -> Self {
Self {
request_id: request_id.into(),
result: Ok(output),
}
}
pub fn error(request_id: impl Into<String>, error: CallError) -> Self {
Self {
request_id: request_id.into(),
result: Err(error),
}
}
pub fn not_found(request_id: impl Into<String>, op_name: &str) -> Self {
Self::error(request_id, CallError::not_found(op_name))
}
pub fn forbidden(request_id: impl Into<String>, message: impl Into<String>) -> Self {
Self::error(request_id, CallError::forbidden(message))
}
pub fn into_event(self) -> EventEnvelope {
let id = self.request_id;
match self.result {
Ok(output) => EventEnvelope::responded(id, output),
Err(ref err) => EventEnvelope::error(id, err),
}
}
}
impl From<ResponseEnvelope> for EventEnvelope {
fn from(envelope: ResponseEnvelope) -> EventEnvelope {
envelope.into_event()
}
}
#[derive(Debug, thiserror::Error)]
pub enum FrameError {
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("connection closed")]
ConnectionClosed,
#[error("invalid frame")]
InvalidFrame,
}
pub struct FrameFramedReader<R: AsyncRead + Unpin> {
reader: R,
len_buf: [u8; LENGTH_PREFIX_BYTES],
}
impl<R: AsyncRead + Unpin> FrameFramedReader<R> {
pub fn new(reader: R) -> Self {
Self {
reader,
len_buf: [0u8; LENGTH_PREFIX_BYTES],
}
}
pub fn into_inner(self) -> R {
self.reader
}
pub async fn read_frame(&mut self) -> Result<EventEnvelope, FrameError> {
match self.reader.read_exact(&mut self.len_buf).await {
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
return Err(FrameError::ConnectionClosed);
}
Err(e) => return Err(FrameError::Io(e)),
}
let length = u32::from_be_bytes(self.len_buf);
if length == 0 {
return Err(FrameError::InvalidFrame);
}
if length > MAX_FRAME_SIZE {
return Err(FrameError::InvalidFrame);
}
let mut body = vec![0u8; length as usize];
match self.reader.read_exact(&mut body).await {
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
return Err(FrameError::ConnectionClosed);
}
Err(e) => return Err(FrameError::Io(e)),
}
let envelope: EventEnvelope = serde_json::from_slice(&body)?;
Ok(envelope)
}
}
pub struct FrameFramedWriter<W: AsyncWrite + Unpin> {
writer: W,
}
impl<W: AsyncWrite + Unpin> FrameFramedWriter<W> {
pub fn new(writer: W) -> Self {
Self { writer }
}
pub fn into_inner(self) -> W {
self.writer
}
pub async fn write_frame(&mut self, envelope: &EventEnvelope) -> Result<(), FrameError> {
let body = serde_json::to_vec(envelope)?;
let len = body.len();
if len > MAX_FRAME_SIZE as usize {
return Err(FrameError::InvalidFrame);
}
let len_bytes = (len as u32).to_be_bytes();
self.writer.write_all(&len_bytes).await?;
self.writer.write_all(&body).await?;
self.writer.flush().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, AsyncReadExt};
fn sample_envelope() -> EventEnvelope {
EventEnvelope::new(
"call.requested",
"req-1",
serde_json::json!({
"operationId": "/fs/readFile",
"input": { "path": "/etc/hosts" }
}),
)
}
#[tokio::test]
async fn round_trip_envelope() {
let (client, server) = duplex(8 * 1024);
let envelope = sample_envelope();
let mut writer = FrameFramedWriter::new(client);
writer.write_frame(&envelope).await.unwrap();
drop(writer);
let mut reader = FrameFramedReader::new(server);
let read = reader.read_frame().await.unwrap();
assert_eq!(read, envelope);
}
#[tokio::test]
async fn round_trip_multiple_frames() {
let (client, server) = duplex(8 * 1024);
let envelopes = vec![
EventEnvelope::responded("a", Value::String("hello".into())),
EventEnvelope::completed("a"),
EventEnvelope::aborted("b"),
];
{
let mut writer = FrameFramedWriter::new(client);
for e in &envelopes {
writer.write_frame(e).await.unwrap();
}
}
let mut reader = FrameFramedReader::new(server);
for expected in envelopes {
let read = reader.read_frame().await.unwrap();
assert_eq!(read, expected);
}
}
#[tokio::test]
async fn read_frame_on_closed_reader_returns_connection_closed() {
let (_, server) = duplex(8 * 1024);
let mut reader = FrameFramedReader::new(server);
match reader.read_frame().await {
Err(FrameError::ConnectionClosed) => {}
other => panic!("expected ConnectionClosed, got {other:?}"),
}
}
#[tokio::test]
async fn truncated_body_returns_connection_closed() {
let (mut client, server) = duplex(8 * 1024);
let envelope = sample_envelope();
let body = serde_json::to_vec(&envelope).unwrap();
let len_bytes = (body.len() as u32).to_be_bytes();
client.write_all(&len_bytes).await.unwrap();
client.write_all(&body[..body.len() / 2]).await.unwrap();
drop(client);
let mut reader = FrameFramedReader::new(server);
match reader.read_frame().await {
Err(FrameError::ConnectionClosed) => {}
other => panic!("expected ConnectionClosed, got {other:?}"),
}
}
#[tokio::test]
async fn zero_length_frame_is_invalid() {
let (mut client, server) = duplex(8 * 1024);
client.write_all(&[0u8, 0, 0, 0]).await.unwrap();
drop(client);
let mut reader = FrameFramedReader::new(server);
match reader.read_frame().await {
Err(FrameError::InvalidFrame) => {}
other => panic!("expected InvalidFrame, got {other:?}"),
}
}
#[tokio::test]
async fn oversized_frame_is_invalid() {
let (mut client, server) = duplex(8 * 1024);
let too_big = (MAX_FRAME_SIZE + 1u32).to_be_bytes();
client.write_all(&too_big).await.unwrap();
drop(client);
let mut reader = FrameFramedReader::new(server);
match reader.read_frame().await {
Err(FrameError::InvalidFrame) => {}
other => panic!("expected InvalidFrame, got {other:?}"),
}
}
#[tokio::test]
async fn framing_handles_large_payload() {
let (client, server) = duplex(1024 * 1024);
let big = "x".repeat(64 * 1024);
let envelope = EventEnvelope::responded("big", Value::String(big.clone()));
let mut writer = FrameFramedWriter::new(client);
writer.write_frame(&envelope).await.unwrap();
drop(writer);
let mut reader = FrameFramedReader::new(server);
let read = reader.read_frame().await.unwrap();
assert_eq!(read, envelope);
match read.payload {
Value::Object(map) => match map.get("output") {
Some(Value::String(s)) => assert_eq!(s, &big),
other => panic!("expected output string, got {other:?}"),
},
other => panic!("expected object payload, got {other:?}"),
}
}
#[test]
fn response_envelope_ok_produces_call_responded_event() {
let response = ResponseEnvelope::ok("req-1", Value::String("hi".into()));
let event: EventEnvelope = response.into();
assert_eq!(event.r#type, EVENT_RESPONDED);
assert_eq!(event.id, "req-1");
let map = event.payload.as_object().expect("payload is object");
assert_eq!(map.get("output"), Some(&Value::String("hi".into())));
}
#[test]
fn response_envelope_error_produces_call_error_event() {
let err = CallError::new("FILE_NOT_FOUND", "file not found: /etc/x", false)
.with_details(serde_json::json!({ "path": "/etc/x" }));
let response = ResponseEnvelope::error("req-2", err);
let event: EventEnvelope = response.into();
assert_eq!(event.r#type, EVENT_ERROR);
assert_eq!(event.id, "req-2");
assert_eq!(
event.payload.get("code"),
Some(&Value::String("FILE_NOT_FOUND".into()))
);
assert_eq!(
event.payload.get("message"),
Some(&Value::String("file not found: /etc/x".into()))
);
assert_eq!(event.payload.get("retryable"), Some(&Value::Bool(false)));
assert_eq!(
event.payload.get("details"),
Some(&serde_json::json!({ "path": "/etc/x" }))
);
}
#[test]
fn response_envelope_not_found_helper() {
let response = ResponseEnvelope::not_found("req-3", "fs/missing");
assert_eq!(response.request_id, "req-3");
match &response.result {
Err(e) => {
assert_eq!(e.code, "NOT_FOUND");
assert!(!e.retryable);
assert!(e.message.contains("fs/missing"));
}
other => panic!("expected Err, got {other:?}"),
}
let event: EventEnvelope = response.into();
assert_eq!(event.r#type, EVENT_ERROR);
assert_eq!(event.id, "req-3");
assert_eq!(
event.payload.get("code"),
Some(&Value::String("NOT_FOUND".into()))
);
}
#[test]
fn response_envelope_forbidden_helper() {
let response = ResponseEnvelope::forbidden("req-4", "authentication required");
match &response.result {
Err(e) => {
assert_eq!(e.code, "FORBIDDEN");
assert_eq!(e.message, "authentication required");
}
other => panic!("expected Err, got {other:?}"),
}
let event: EventEnvelope = response.into();
assert_eq!(event.r#type, EVENT_ERROR);
assert_eq!(event.id, "req-4");
}
#[test]
fn event_envelope_completed_has_empty_payload() {
let event = EventEnvelope::completed("sub-1");
assert_eq!(event.r#type, EVENT_COMPLETED);
assert_eq!(event.id, "sub-1");
assert_eq!(event.payload, serde_json::json!({}));
}
#[test]
fn event_envelope_aborted_has_empty_payload() {
let event = EventEnvelope::aborted("req-9");
assert_eq!(event.r#type, EVENT_ABORTED);
assert_eq!(event.id, "req-9");
assert_eq!(event.payload, serde_json::json!({}));
}
#[test]
fn event_envelope_responded_wraps_output() {
let event = EventEnvelope::responded("req-1", Value::Number(42.into()));
assert_eq!(event.r#type, EVENT_RESPONDED);
assert_eq!(event.payload.get("output"), Some(&Value::Number(42.into())));
}
#[test]
fn event_envelope_serializes_type_field() {
let event = sample_envelope();
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"call.requested\""));
assert!(!json.contains("\"r#type\""));
let parsed: EventEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, event);
}
#[test]
fn call_error_skips_missing_details() {
let err = CallError::new("INTERNAL", "boom", false);
let json = serde_json::to_string(&err).unwrap();
assert!(!json.contains("details"));
}
#[tokio::test]
async fn read_after_eof_then_eof_returns_connection_closed() {
let mut data = Vec::new();
let envelope = EventEnvelope::responded("one", Value::Null);
let body = serde_json::to_vec(&envelope).unwrap();
data.extend_from_slice(&(body.len() as u32).to_be_bytes());
data.extend_from_slice(&body);
let cursor = std::io::Cursor::new(data);
let mut reader = FrameFramedReader::new(cursor);
let first = reader.read_frame().await.unwrap();
assert_eq!(first, envelope);
match reader.read_frame().await {
Err(FrameError::ConnectionClosed) => {}
other => panic!("expected ConnectionClosed, got {other:?}"),
}
}
#[tokio::test]
async fn writer_into_inner_recovers_stream() {
let (client, server) = duplex(8 * 1024);
let envelope = sample_envelope();
let mut writer = FrameFramedWriter::new(client);
writer.write_frame(&envelope).await.unwrap();
let mut recovered = writer.into_inner();
recovered.shutdown().await.unwrap();
drop(recovered);
let mut reader = FrameFramedReader::new(server);
let read = reader.read_frame().await.unwrap();
assert_eq!(read, envelope);
let _ = reader.into_inner();
}
#[tokio::test]
async fn reader_handles_partial_length_prefix() {
let (mut client, server) = duplex(8 * 1024);
client.write_all(&[0u8, 0]).await.unwrap();
drop(client);
let mut reader = FrameFramedReader::new(server);
match reader.read_frame().await {
Err(FrameError::ConnectionClosed) => {}
other => panic!("expected ConnectionClosed, got {other:?}"),
}
}
#[tokio::test]
async fn reader_drains_remaining_after_read() {
let mut data = Vec::new();
let envelope = sample_envelope();
let body = serde_json::to_vec(&envelope).unwrap();
data.extend_from_slice(&(body.len() as u32).to_be_bytes());
data.extend_from_slice(&body);
data.extend_from_slice(&[9u8; 4]);
let mut cursor = tokio::io::BufReader::new(std::io::Cursor::new(data));
let mut reader = FrameFramedReader::new(&mut cursor);
let read = reader.read_frame().await.unwrap();
assert_eq!(read, envelope);
let mut leftover = Vec::new();
let _ = cursor.read_to_end(&mut leftover).await.unwrap();
assert_eq!(leftover, vec![9u8; 4]);
}
}

View File

@@ -0,0 +1,308 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Instant;
use alknet_core::auth::Identity;
use alknet_core::types::Capabilities;
use serde_json::Value;
use super::env::{OperationEnv, PeerId, PeerRef};
pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<Identity>,
pub handler_identity: Option<CompositionAuthority>,
/// The original caller when this call was forwarded by a `from_call`
/// handler (ADR-032). **Metadata only** — `AccessControl::check` never
/// reads it; the ACL always authorizes `identity` (the direct caller).
/// Handlers may read it for logging, auditing, per-user rate limiting,
/// or application context. Populated from
/// `call.requested.forwarded_for` by the dispatch path; set to `None`
/// for composed children (wire-ingress only, not composition-ingress).
/// The forwarder's claim, not a verified identity — a malicious hub can
/// lie (same property as HTTP `X-Forwarded-For`). See ADR-032.
pub forwarded_for: Option<Identity>,
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
pub scoped_env: ScopedPeerEnv,
pub env: Arc<dyn OperationEnv + Send + Sync>,
pub abort_policy: AbortPolicy,
pub deadline: Option<Instant>,
pub internal: bool,
}
impl OperationContext {
pub fn is_internal(&self) -> bool {
self.internal
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AbortPolicy {
#[default]
AbortDependents,
ContinueRunning,
}
#[derive(Debug, Clone)]
pub struct CompositionAuthority {
pub label: String,
pub scopes: Vec<String>,
pub resources: HashMap<String, Vec<String>>,
}
impl CompositionAuthority {
pub fn none() -> Option<Self> {
None
}
pub fn new(label: &str, scopes: impl IntoIterator<Item = String>) -> Self {
Self {
label: label.to_string(),
scopes: scopes.into_iter().collect(),
resources: HashMap::new(),
}
}
pub fn as_identity(&self) -> Option<Identity> {
Some(Identity {
id: self.label.clone(),
scopes: self.scopes.clone(),
resources: self.resources.clone(),
})
}
}
#[derive(Debug, Clone)]
pub struct ScopedPeerEnv {
/// Peer-agnostic reachability — reachable via `PeerRef::Any` or
/// `PeerRef::Specific(any)`. The common case (peer-agnostic composition).
pub allowed_ops: HashSet<String>,
/// Peer-pinned reachability — `"peer-id/op-name"`, reachable only via
/// `PeerRef::Specific(that peer)`. Additive to `allowed_ops`; opt-in for
/// the disambiguation case (ADR-029 §4).
pub peer_pinned: HashSet<String>,
}
impl ScopedPeerEnv {
pub fn empty() -> Self {
Self {
allowed_ops: HashSet::new(),
peer_pinned: HashSet::new(),
}
}
pub fn new(ops: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
allowed_ops: ops.into_iter().map(|s| s.into()).collect(),
peer_pinned: HashSet::new(),
}
}
/// Peer-pinned reachability: `"peer-id/op-name"`. Reachable only via
/// `PeerRef::Specific(that peer)`. Additive to `new` — call `new` for the
/// peer-agnostic set, then `with_pinned` for the pinned set.
pub fn with_pinned(mut self, pinned: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.peer_pinned = pinned.into_iter().map(|s| s.into()).collect();
self
}
/// Peer-agnostic reachability — unchanged from `ScopedOperationEnv::allows`.
/// A name here is reachable via any routing path (`PeerRef::Any` or
/// `Specific`).
pub fn allows(&self, name: &str) -> bool {
self.allowed_ops.contains(name)
}
/// Peer-pinned reachability — reachable only via `PeerRef::Specific(peer)`.
/// The entry shape is `"peer-id/op-name"` (ADR-029 §4, OQ-33).
pub fn allows_pinned(&self, peer: &PeerId, name: &str) -> bool {
self.peer_pinned.contains(&format!("{peer}/{name}"))
}
/// Does this scoped env permit `name` via `peer`? Used by the reachability
/// gate in `invoke_peer` / `invoke_with_policy`.
/// - `PeerRef::Any` → `allows(name)`
/// - `PeerRef::Specific(peer)` → `allows(name) || allows_pinned(peer, name)`
pub fn allows_via(&self, peer: &PeerRef, name: &str) -> bool {
match peer {
PeerRef::Any => self.allows(name),
PeerRef::Specific(p) => self.allows(name) || self.allows_pinned(p, name),
}
}
}
impl Default for ScopedPeerEnv {
fn default() -> Self {
Self::empty()
}
}
#[allow(dead_code)]
pub(crate) fn generate_request_id() -> String {
uuid::Uuid::new_v4().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scoped_env_allows_in_set() {
let env = ScopedPeerEnv::new(["fs/readFile", "agent/chat"]);
assert!(env.allows("fs/readFile"));
assert!(env.allows("agent/chat"));
}
#[test]
fn scoped_env_disallows_not_in_set() {
let env = ScopedPeerEnv::new(["fs/readFile"]);
assert!(!env.allows("agent/chat"));
assert!(!env.allows(""));
}
#[test]
fn scoped_env_empty_allows_nothing() {
let env = ScopedPeerEnv::empty();
assert!(!env.allows("fs/readFile"));
}
#[test]
fn scoped_peer_env_new_with_pinned_populates_both_fields() {
let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]);
assert!(env.allowed_ops.contains("fs/readFile"));
assert!(env.peer_pinned.contains("worker-a/container/exec"));
assert!(!env.allowed_ops.contains("worker-a/container/exec"));
assert!(!env.peer_pinned.contains("fs/readFile"));
}
#[test]
fn scoped_peer_env_allows_checks_allowed_ops_only() {
let env = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
assert!(
!env.allows("container/exec"),
"pinned-only op not in allowed_ops"
);
let env2 = ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]);
assert!(
env2.allows("container/exec"),
"op in allowed_ops is allowed"
);
}
#[test]
fn scoped_peer_env_allows_pinned_checks_peer_pinned_shape() {
let env = ScopedPeerEnv::empty().with_pinned(["worker-a/container/exec"]);
assert!(env.allows_pinned(&"worker-a".to_string(), "container/exec"));
assert!(
!env.allows_pinned(&"worker-b".to_string(), "container/exec"),
"wrong peer"
);
assert!(
!env.allows_pinned(&"worker-a".to_string(), "other/op"),
"wrong op"
);
}
#[test]
fn scoped_peer_env_allows_via_any_uses_allowed_ops_only() {
let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]);
assert!(
env.allows_via(&PeerRef::Any, "fs/readFile"),
"allowed op via Any"
);
assert!(
!env.allows_via(&PeerRef::Any, "container/exec"),
"pinned-only op NOT reachable via Any"
);
}
#[test]
fn scoped_peer_env_allows_via_specific_uses_allowed_ops_or_peer_pinned() {
let env = ScopedPeerEnv::new(["fs/readFile"]).with_pinned(["worker-a/container/exec"]);
assert!(
env.allows_via(&PeerRef::Specific("worker-a".to_string()), "container/exec"),
"pinned-only op reachable via Specific(pinned peer)"
);
assert!(
env.allows_via(&PeerRef::Specific("worker-a".to_string()), "fs/readFile"),
"allowed op reachable via Specific(any peer)"
);
assert!(
!env.allows_via(&PeerRef::Specific("worker-b".to_string()), "container/exec"),
"pinned-only op NOT reachable via Specific(wrong peer)"
);
}
#[test]
fn scoped_peer_env_op_in_both_sets_reachable_via_both_any_and_specific() {
let env = ScopedPeerEnv::new(["container/exec"]).with_pinned(["worker-a/container/exec"]);
assert!(
env.allows_via(&PeerRef::Any, "container/exec"),
"op in allowed_ops reachable via Any"
);
assert!(
env.allows_via(&PeerRef::Specific("worker-a".to_string()), "container/exec"),
"op in both sets reachable via Specific(peer)"
);
assert!(
env.allows_via(&PeerRef::Specific("worker-b".to_string()), "container/exec"),
"op in allowed_ops reachable via Specific(other peer) too"
);
}
#[test]
fn composition_authority_as_identity_correct() {
let mut resources = HashMap::new();
resources.insert("service".to_string(), vec!["vastai".to_string()]);
let authority = CompositionAuthority {
label: "agent-chat".to_string(),
scopes: vec!["llm:call".to_string(), "fs:read".to_string()],
resources,
};
let identity = authority.as_identity().expect("as_identity returns Some");
assert_eq!(identity.id, "agent-chat");
assert_eq!(
identity.scopes,
vec!["llm:call".to_string(), "fs:read".to_string()]
);
assert_eq!(
identity.resources.get("service"),
Some(&vec!["vastai".to_string()])
);
}
#[test]
fn composition_authority_new_populates_label_and_scopes() {
let authority = CompositionAuthority::new(
"agent-chat",
["llm:call".to_string(), "fs:read".to_string()],
);
assert_eq!(authority.label, "agent-chat");
assert_eq!(
authority.scopes,
vec!["llm:call".to_string(), "fs:read".to_string()]
);
assert!(authority.resources.is_empty());
}
#[test]
fn composition_authority_none_is_none() {
assert!(CompositionAuthority::none().is_none());
}
#[test]
fn abort_policy_default_is_abort_dependents() {
let policy = AbortPolicy::default();
assert!(matches!(policy, AbortPolicy::AbortDependents));
}
#[test]
fn generate_request_id_is_unique_and_non_deterministic() {
let a = generate_request_id();
let b = generate_request_id();
assert_ne!(a, b);
assert!(!a.is_empty());
}
}

View File

@@ -0,0 +1,979 @@
use std::sync::Arc;
use serde_json::{json, Value};
use super::context::OperationContext;
use super::registration::{Handler, OperationRegistry};
use super::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use crate::protocol::wire::{CallError, ResponseEnvelope};
const NAME_SERVICES_LIST: &str = "services/list";
const NAME_SERVICES_LIST_PEERS: &str = "services/list-peers";
const NAME_SERVICES_SCHEMA: &str = "services/schema";
pub fn services_list_spec() -> OperationSpec {
OperationSpec::new(
NAME_SERVICES_LIST,
OperationType::Query,
Visibility::External,
json!({}),
json!({
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"namespace": { "type": "string" },
"op_type": {
"type": "string",
"enum": ["query", "mutation", "subscription"]
}
}
}
}
}
}),
vec![],
AccessControl::default(),
)
}
pub fn services_schema_spec() -> OperationSpec {
OperationSpec::new(
NAME_SERVICES_SCHEMA,
OperationType::Query,
Visibility::External,
json!({
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
}),
operation_spec_schema(),
vec![],
AccessControl::default(),
)
}
pub fn services_list_peers_spec() -> OperationSpec {
OperationSpec::new(
NAME_SERVICES_LIST_PEERS,
OperationType::Query,
Visibility::External,
json!({}),
json!({
"type": "object",
"properties": {
"peers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"peer_id": { "type": "string" },
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"namespace": { "type": "string" },
"op_type": {
"type": "string",
"enum": ["query", "mutation", "subscription"]
}
}
}
}
}
}
}
}
}),
vec![],
AccessControl::default(),
)
}
fn operation_spec_schema() -> Value {
json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"namespace": { "type": "string" },
"op_type": {
"type": "string",
"enum": ["query", "mutation", "subscription"]
},
"visibility": {
"type": "string",
"enum": ["external", "internal"]
},
"input_schema": {},
"output_schema": {},
"error_schemas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"code": { "type": "string" },
"description": { "type": "string" },
"schema": {},
"http_status": { "type": ["integer", "null"] }
}
}
},
"access_control": {
"type": "object",
"properties": {
"required_scopes": {
"type": "array",
"items": { "type": "string" }
},
"required_scopes_any": {
"type": ["array", "null"],
"items": { "type": "string" }
},
"resource_type": { "type": ["string", "null"] },
"resource_action": { "type": ["string", "null"] }
}
}
},
"required": [
"name",
"namespace",
"op_type",
"visibility",
"input_schema",
"output_schema",
"error_schemas",
"access_control"
]
})
}
fn op_type_str(op_type: OperationType) -> &'static str {
match op_type {
OperationType::Query => "query",
OperationType::Mutation => "mutation",
OperationType::Subscription => "subscription",
}
}
fn visibility_str(visibility: Visibility) -> &'static str {
match visibility {
Visibility::External => "external",
Visibility::Internal => "internal",
}
}
fn access_control_to_json(acl: &AccessControl) -> Value {
json!({
"required_scopes": acl.required_scopes,
"required_scopes_any": acl.required_scopes_any,
"resource_type": acl.resource_type,
"resource_action": acl.resource_action,
})
}
fn error_definition_to_json(def: &super::spec::ErrorDefinition) -> Value {
json!({
"code": def.code,
"description": def.description,
"schema": def.schema,
"http_status": def.http_status,
})
}
fn spec_to_json(spec: &OperationSpec) -> Value {
let error_schemas: Vec<Value> = spec
.error_schemas
.iter()
.map(error_definition_to_json)
.collect();
json!({
"name": spec.name,
"namespace": spec.namespace,
"op_type": op_type_str(spec.op_type),
"visibility": visibility_str(spec.visibility),
"input_schema": spec.input_schema,
"output_schema": spec.output_schema,
"error_schemas": error_schemas,
"access_control": access_control_to_json(&spec.access_control),
})
}
fn normalize_name(name: &str) -> String {
if let Some(rest) = name.strip_prefix('/') {
rest.to_string()
} else {
name.to_string()
}
}
pub fn services_list_handler(registry: Arc<OperationRegistry>) -> Handler {
Arc::new(move |input: Value, ctx: OperationContext| {
let registry = Arc::clone(&registry);
Box::pin(async move {
let _ = input;
let calling_identity = ctx.identity.as_ref();
let ops: Vec<Value> = registry
.list_operations()
.into_iter()
.filter(|spec| spec.access_control.check(calling_identity).is_allowed())
.map(|s| {
json!({
"name": s.name,
"namespace": s.namespace,
"op_type": op_type_str(s.op_type),
})
})
.collect();
ResponseEnvelope::ok(ctx.request_id, json!({ "operations": ops }))
})
})
}
pub fn services_list_peers_handler(registry: Arc<OperationRegistry>) -> Handler {
Arc::new(move |input: Value, ctx: OperationContext| {
let registry = Arc::clone(&registry);
Box::pin(async move {
let _ = input;
let calling_identity = ctx.identity.as_ref();
let local_ops: Vec<Value> = registry
.list_operations()
.into_iter()
.filter(|spec| spec.access_control.check(calling_identity).is_allowed())
.map(|s| {
json!({
"name": s.name,
"namespace": s.namespace,
"op_type": op_type_str(s.op_type),
})
})
.collect();
let mut peers: Vec<Value> = Vec::new();
if !local_ops.is_empty() {
peers.push(json!({ "peer_id": "local", "operations": local_ops }));
}
for peer_id in ctx.env.peer_ids() {
let peer_ops: Vec<Value> = ctx
.env
.peer_operations(&peer_id)
.into_iter()
.filter(|name| {
let spec = registry.registration(name);
match spec {
Some(reg) => {
reg.spec.access_control.check(calling_identity).is_allowed()
}
None => true,
}
})
.map(name_to_listing_json)
.collect();
if !peer_ops.is_empty() {
peers.push(json!({ "peer_id": peer_id, "operations": peer_ops }));
}
}
ResponseEnvelope::ok(ctx.request_id, json!({ "peers": peers }))
})
})
}
fn name_to_listing_json(name: String) -> Value {
let namespace = name
.split('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("")
.to_string();
json!({
"name": name,
"namespace": namespace,
"op_type": "query",
})
}
pub fn services_schema_handler(registry: Arc<OperationRegistry>) -> Handler {
Arc::new(move |input: Value, ctx: OperationContext| {
let registry = Arc::clone(&registry);
Box::pin(async move {
let name = match input.get("name").and_then(|v| v.as_str()) {
Some(n) => normalize_name(n),
None => {
return ResponseEnvelope::error(
ctx.request_id,
CallError::invalid_input("missing required field: name"),
);
}
};
match registry.registration(&name) {
Some(reg) => {
let spec_json = spec_to_json(&reg.spec);
ResponseEnvelope::ok(ctx.request_id, spec_json)
}
None => ResponseEnvelope::not_found(ctx.request_id, &name),
}
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::context::{CompositionAuthority, ScopedPeerEnv};
use crate::registry::registration::{
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration,
OperationProvenance, StreamingHandler,
};
use alknet_core::types::Capabilities;
use std::collections::HashMap;
use std::time::Duration;
fn external_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
)
}
fn internal_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Mutation,
Visibility::Internal,
json!({}),
json!({}),
vec![],
AccessControl::default(),
)
}
fn echo_handler() -> Handler {
make_handler(
|input, context| async move { ResponseEnvelope::ok(context.request_id, input) },
)
}
fn echo_streaming_handler() -> StreamingHandler {
make_streaming_handler(|input, context| {
futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)])
})
}
fn noop_env() -> Arc<dyn crate::registry::env::OperationEnv + Send + Sync> {
struct NoopEnv;
#[async_trait::async_trait]
impl crate::registry::env::OperationEnv for NoopEnv {
async fn invoke_with_policy(
&self,
_ns: &str,
_op: &str,
_input: Value,
_parent: &OperationContext,
_policy: crate::registry::context::AbortPolicy,
) -> ResponseEnvelope {
ResponseEnvelope::error("test", CallError::internal("noop env does not dispatch"))
}
fn contains(&self, _name: &str) -> bool {
false
}
}
Arc::new(NoopEnv)
}
fn root_context(request_id: &str) -> OperationContext {
OperationContext {
request_id: request_id.to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env: noop_env(),
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: false,
}
}
fn root_context_with_identity(
request_id: &str,
identity: Option<alknet_core::auth::Identity>,
) -> OperationContext {
OperationContext {
request_id: request_id.to_string(),
parent_request_id: None,
identity,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env: noop_env(),
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: false,
}
}
fn identity_with_scopes(id: &str, scopes: &[&str]) -> alknet_core::auth::Identity {
alknet_core::auth::Identity {
id: id.to_string(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
fn external_spec_with_acl(name: &str, acl: AccessControl) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
vec![],
acl,
)
}
fn registry_with_access_controlled_ops() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec_with_acl("public/echo", AccessControl::default()),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
external_spec_with_acl(
"admin/secret",
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
internal_spec("internal/hidden"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
fn op_names(response: ResponseEnvelope) -> Vec<String> {
let output = response.result.expect("ok response");
output
.get("operations")
.and_then(|v| v.as_array())
.expect("operations array")
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from))
.collect()
}
fn registry_with_ops() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("fs/readFile"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
internal_spec("secret/internal"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"events/subscribe",
OperationType::Subscription,
Visibility::External,
json!({}),
json!({}),
vec![],
AccessControl::default(),
),
HandlerKind::Stream(echo_streaming_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
OperationSpec::new(
"fs/readFileErr",
OperationType::Query,
Visibility::External,
json!({}),
json!({}),
vec![super::super::spec::ErrorDefinition {
code: "FILE_NOT_FOUND".to_string(),
description: "file not found".to_string(),
schema: json!({ "type": "object" }),
http_status: None,
}],
AccessControl::default(),
),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(registry)
}
#[test]
fn services_list_spec_has_correct_fields() {
let spec = services_list_spec();
assert_eq!(spec.name, NAME_SERVICES_LIST);
assert_eq!(spec.namespace, "services");
assert_eq!(spec.op_type, OperationType::Query);
assert_eq!(spec.visibility, Visibility::External);
assert_eq!(spec.input_schema, json!({}));
assert!(spec.output_schema.get("properties").is_some());
assert!(spec.error_schemas.is_empty());
assert!(!spec.access_control.has_restrictions());
}
#[test]
fn services_schema_spec_has_correct_fields() {
let spec = services_schema_spec();
assert_eq!(spec.name, NAME_SERVICES_SCHEMA);
assert_eq!(spec.namespace, "services");
assert_eq!(spec.op_type, OperationType::Query);
assert_eq!(spec.visibility, Visibility::External);
assert!(spec.input_schema.get("required").is_some());
assert!(spec.output_schema.get("properties").is_some());
assert!(spec.error_schemas.is_empty());
assert!(!spec.access_control.has_restrictions());
}
#[tokio::test]
async fn services_list_returns_external_ops_only() {
let registry = registry_with_ops();
let handler = services_list_handler(Arc::clone(&registry));
let ctx = root_context("req-1");
let response = handler(serde_json::json!({}), ctx).await;
let output = response.result.expect("ok response");
let ops = output
.get("operations")
.and_then(|v| v.as_array())
.expect("operations array");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"fs/readFile"));
assert!(names.contains(&"events/subscribe"));
assert!(names.contains(&"fs/readFileErr"));
assert!(
!names.contains(&"secret/internal"),
"internal ops must not be listed"
);
}
#[tokio::test]
async fn services_list_output_format_matches_spec() {
let registry = registry_with_ops();
let handler = services_list_handler(Arc::clone(&registry));
let ctx = root_context("req-1");
let response = handler(serde_json::json!({}), ctx).await;
let output = response.result.expect("ok response");
let ops = output
.get("operations")
.and_then(|v| v.as_array())
.expect("operations array");
let fs_op = ops
.iter()
.find(|o| o.get("name").and_then(|n| n.as_str()) == Some("fs/readFile"))
.expect("fs/readFile present");
assert_eq!(fs_op.get("namespace"), Some(&json!("fs")));
assert_eq!(fs_op.get("op_type"), Some(&json!("query")));
}
#[tokio::test]
async fn services_schema_returns_spec_for_known_op() {
let registry = registry_with_ops();
let handler = services_schema_handler(Arc::clone(&registry));
let ctx = root_context("req-2");
let response = handler(serde_json::json!({ "name": "fs/readFileErr" }), ctx).await;
let spec = response.result.expect("ok response");
assert_eq!(spec.get("name"), Some(&json!("fs/readFileErr")));
assert_eq!(spec.get("namespace"), Some(&json!("fs")));
assert_eq!(spec.get("op_type"), Some(&json!("query")));
let error_schemas = spec
.get("error_schemas")
.and_then(|v| v.as_array())
.expect("error_schemas array");
assert_eq!(error_schemas.len(), 1);
assert_eq!(error_schemas[0].get("code"), Some(&json!("FILE_NOT_FOUND")));
}
#[tokio::test]
async fn services_schema_returns_not_found_for_unknown_op() {
let registry = registry_with_ops();
let handler = services_schema_handler(Arc::clone(&registry));
let ctx = root_context("req-3");
let response = handler(serde_json::json!({ "name": "no/such" }), ctx).await;
match response.result {
Err(e) => assert_eq!(e.code, "NOT_FOUND"),
other => panic!("expected NOT_FOUND, got {other:?}"),
}
}
#[tokio::test]
async fn services_schema_accepts_name_with_leading_slash() {
let registry = registry_with_ops();
let handler = services_schema_handler(Arc::clone(&registry));
let ctx = root_context("req-4");
let response = handler(serde_json::json!({ "name": "/fs/readFile" }), ctx).await;
let spec = response.result.expect("ok response");
assert_eq!(spec.get("name"), Some(&json!("fs/readFile")));
}
#[tokio::test]
async fn services_schema_rejects_missing_name() {
let registry = registry_with_ops();
let handler = services_schema_handler(Arc::clone(&registry));
let ctx = root_context("req-5");
let response = handler(serde_json::json!({}), ctx).await;
match response.result {
Err(e) => assert_eq!(e.code, "INVALID_INPUT"),
other => panic!("expected INVALID_INPUT, got {other:?}"),
}
}
#[tokio::test]
async fn services_list_handler_registered_and_invocable_via_registry() {
let registry = registry_with_ops();
let list_handler = services_list_handler(Arc::clone(&registry));
let schema_handler = services_schema_handler(Arc::clone(&registry));
let mut discovery_registry = OperationRegistry::new();
discovery_registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(list_handler),
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
discovery_registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(schema_handler),
OperationProvenance::Local,
CompositionAuthority::none(),
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
let discovery = Arc::new(discovery_registry);
let ctx = root_context("req-6");
let response = discovery
.invoke(NAME_SERVICES_LIST, serde_json::json!({}), ctx)
.await;
let output = response.result.expect("list ok");
assert!(output.get("operations").is_some());
}
#[test]
fn normalize_name_strips_leading_slash() {
assert_eq!(normalize_name("/fs/readFile"), "fs/readFile");
assert_eq!(normalize_name("fs/readFile"), "fs/readFile");
}
#[test]
fn op_type_str_matches_wire_enum() {
assert_eq!(op_type_str(OperationType::Query), "query");
assert_eq!(op_type_str(OperationType::Mutation), "mutation");
assert_eq!(op_type_str(OperationType::Subscription), "subscription");
}
#[test]
fn visibility_str_matches_wire_enum() {
assert_eq!(visibility_str(Visibility::External), "external");
assert_eq!(visibility_str(Visibility::Internal), "internal");
}
#[test]
fn spec_to_json_round_trips_error_schemas() {
let spec = OperationSpec::new(
"fs/readFile",
OperationType::Query,
Visibility::External,
json!({ "type": "object" }),
json!({ "type": "string" }),
vec![super::super::spec::ErrorDefinition {
code: "FILE_NOT_FOUND".to_string(),
description: "file not found".to_string(),
schema: json!({ "type": "object", "properties": { "path": { "type": "string" } } }),
http_status: Some(404),
}],
AccessControl {
required_scopes: vec!["fs:read".to_string()],
..Default::default()
},
);
let json_val = spec_to_json(&spec);
let error_schemas = json_val
.get("error_schemas")
.and_then(|v| v.as_array())
.expect("error_schemas");
assert_eq!(error_schemas.len(), 1);
assert_eq!(error_schemas[0].get("code"), Some(&json!("FILE_NOT_FOUND")));
assert_eq!(error_schemas[0].get("http_status"), Some(&json!(404)));
let acl = json_val.get("access_control").expect("access_control");
assert_eq!(acl.get("required_scopes"), Some(&json!(["fs:read"])));
}
#[tokio::test]
async fn services_list_filters_by_access_control_authorized_peer() {
let registry = registry_with_access_controlled_ops();
let handler = services_list_handler(Arc::clone(&registry));
let ctx = root_context_with_identity(
"req-acl-1",
Some(identity_with_scopes("admin-peer", &["admin"])),
);
let names = op_names(handler(serde_json::json!({}), ctx).await);
assert!(names.contains(&"public/echo".to_string()));
assert!(names.contains(&"admin/secret".to_string()));
assert!(!names.contains(&"internal/hidden".to_string()));
}
#[tokio::test]
async fn services_list_filters_by_access_control_unauthorized_peer() {
let registry = registry_with_access_controlled_ops();
let handler = services_list_handler(Arc::clone(&registry));
let ctx = root_context_with_identity(
"req-acl-2",
Some(identity_with_scopes("regular-peer", &["user"])),
);
let names = op_names(handler(serde_json::json!({}), ctx).await);
assert!(names.contains(&"public/echo".to_string()));
assert!(
!names.contains(&"admin/secret".to_string()),
"unauthorized peer must not see admin/secret"
);
assert!(!names.contains(&"internal/hidden".to_string()));
}
#[tokio::test]
async fn services_list_op_with_default_acl_listed_to_any_peer() {
let registry = registry_with_access_controlled_ops();
let handler = services_list_handler(Arc::clone(&registry));
let ctx = root_context_with_identity("req-acl-3", None);
let names = op_names(handler(serde_json::json!({}), ctx).await);
assert!(
names.contains(&"public/echo".to_string()),
"default AccessControl op must be listed to unauthenticated peer"
);
assert!(!names.contains(&"admin/secret".to_string()));
}
#[tokio::test]
async fn services_list_peers_attributes_ops_by_peer_id() {
struct PeerEnv {
peers: HashMap<String, Vec<String>>,
}
#[async_trait::async_trait]
impl crate::registry::env::OperationEnv for PeerEnv {
async fn invoke_with_policy(
&self,
_ns: &str,
_op: &str,
_input: Value,
parent: &OperationContext,
_policy: crate::registry::context::AbortPolicy,
) -> ResponseEnvelope {
ResponseEnvelope::ok(parent.request_id.clone(), json!({}))
}
fn contains(&self, _name: &str) -> bool {
false
}
fn peer_ids(&self) -> Vec<crate::registry::env::PeerId> {
self.peers.keys().cloned().collect()
}
fn peer_operations(&self, peer: &crate::registry::env::PeerId) -> Vec<String> {
self.peers.get(peer).cloned().unwrap_or_default()
}
}
let mut peers = HashMap::new();
peers.insert(
"worker-a".to_string(),
vec!["container/exec".to_string(), "container/logs".to_string()],
);
peers.insert("worker-b".to_string(), vec!["container/exec".to_string()]);
let env: Arc<dyn crate::registry::env::OperationEnv + Send + Sync> =
Arc::new(PeerEnv { peers });
let registry = registry_with_access_controlled_ops();
let handler = services_list_peers_handler(Arc::clone(&registry));
let ctx = OperationContext {
request_id: "req-peers-1".to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env,
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: false,
};
let response = handler(serde_json::json!({}), ctx).await;
let output = response.result.expect("ok response");
let peers_arr = output
.get("peers")
.and_then(|v| v.as_array())
.expect("peers array");
let peer_ids: Vec<&str> = peers_arr
.iter()
.filter_map(|p| p.get("peer_id").and_then(|v| v.as_str()))
.collect();
assert!(peer_ids.contains(&"local"));
assert!(peer_ids.contains(&"worker-a"));
assert!(peer_ids.contains(&"worker-b"));
let worker_a = peers_arr
.iter()
.find(|p| p.get("peer_id").and_then(|v| v.as_str()) == Some("worker-a"))
.expect("worker-a present");
let worker_a_ops = worker_a
.get("operations")
.and_then(|v| v.as_array())
.expect("worker-a operations");
let worker_a_names: Vec<&str> = worker_a_ops
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(worker_a_names.contains(&"container/exec"));
assert!(worker_a_names.contains(&"container/logs"));
}
#[test]
fn services_list_peers_spec_has_correct_fields() {
let spec = services_list_peers_spec();
assert_eq!(spec.name, NAME_SERVICES_LIST_PEERS);
assert_eq!(spec.namespace, "services");
assert_eq!(spec.op_type, OperationType::Query);
assert_eq!(spec.visibility, Visibility::External);
assert!(spec.error_schemas.is_empty());
assert!(!spec.access_control.has_restrictions());
}
#[tokio::test]
async fn services_list_peers_filters_by_access_control() {
struct PeerEnv;
#[async_trait::async_trait]
impl crate::registry::env::OperationEnv for PeerEnv {
async fn invoke_with_policy(
&self,
_ns: &str,
_op: &str,
_input: Value,
parent: &OperationContext,
_policy: crate::registry::context::AbortPolicy,
) -> ResponseEnvelope {
ResponseEnvelope::ok(parent.request_id.clone(), json!({}))
}
fn contains(&self, _name: &str) -> bool {
false
}
fn peer_ids(&self) -> Vec<crate::registry::env::PeerId> {
vec!["restricted-peer".to_string()]
}
fn peer_operations(&self, _peer: &crate::registry::env::PeerId) -> Vec<String> {
vec!["admin/secret".to_string(), "public/echo".to_string()]
}
}
let registry = registry_with_access_controlled_ops();
let handler = services_list_peers_handler(Arc::clone(&registry));
let env: Arc<dyn crate::registry::env::OperationEnv + Send + Sync> = Arc::new(PeerEnv);
let ctx = OperationContext {
request_id: "req-peers-2".to_string(),
parent_request_id: None,
identity: Some(identity_with_scopes("regular-peer", &["user"])),
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: HashMap::new(),
scoped_env: ScopedPeerEnv::empty(),
env,
abort_policy: crate::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: false,
};
let response = handler(serde_json::json!({}), ctx).await;
let output = response.result.expect("ok response");
let peers_arr = output
.get("peers")
.and_then(|v| v.as_array())
.expect("peers array");
let restricted = peers_arr
.iter()
.find(|p| p.get("peer_id").and_then(|v| v.as_str()) == Some("restricted-peer"))
.expect("restricted-peer present");
let ops = restricted
.get("operations")
.and_then(|v| v.as_array())
.expect("operations");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.collect();
assert!(names.contains(&"public/echo"));
assert!(
!names.contains(&"admin/secret"),
"unauthorized peer must not see admin op in list-peers"
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
//! Operation registry: specs, handlers, access control, service discovery.
//!
//! Maps operation names to specs and handlers, enforces access control, and
//! dispatches `call.requested` events to local handlers. The registry is
//! layered by trust boundary (ADR-024): a curated layer (immutable after
//! startup) plus dynamic session and connection overlays.
pub mod context;
pub mod discovery;
pub mod env;
pub mod registration;
pub mod spec;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
//! Operation specifications: `OperationSpec`, `OperationType`, `Visibility`,
//! `ErrorDefinition`, and `AccessControl`.
//!
//! See `docs/architecture/crates/call/operation-registry.md` for the full
//! specification.
use alknet_core::auth::Identity;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationType {
Query,
Mutation,
Subscription,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Visibility {
External,
Internal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorDefinition {
pub code: String,
pub description: String,
pub schema: Value,
pub http_status: Option<u16>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AccessControl {
pub required_scopes: Vec<String>,
pub required_scopes_any: Option<Vec<String>>,
pub resource_type: Option<String>,
pub resource_action: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessResult {
Allowed,
Forbidden(String),
}
impl AccessResult {
pub fn is_allowed(&self) -> bool {
matches!(self, AccessResult::Allowed)
}
}
impl AccessControl {
pub fn has_restrictions(&self) -> bool {
!self.required_scopes.is_empty()
|| self.required_scopes_any.is_some()
|| self.resource_type.is_some()
|| self.resource_action.is_some()
}
pub fn check(&self, identity: Option<&Identity>) -> AccessResult {
if !self.has_restrictions() {
return AccessResult::Allowed;
}
let identity = match identity {
Some(id) => id,
None => return AccessResult::Forbidden("authentication required".to_string()),
};
for scope in &self.required_scopes {
if !identity.scopes.iter().any(|s| s == scope) {
return AccessResult::Forbidden(format!("missing required scope: {scope}"));
}
}
if let Some(any) = &self.required_scopes_any {
let has_one = any.iter().any(|s| identity.scopes.iter().any(|i| i == s));
if !has_one {
return AccessResult::Forbidden(
"missing required scope (any of: ".to_string() + &any.join(", ") + ")",
);
}
}
if let Some(rt) = &self.resource_type {
let allowed = identity.resources.get(rt);
match &self.resource_action {
Some(action) => match allowed {
Some(actions) if actions.iter().any(|a| a == action) => {}
_ => {
return AccessResult::Forbidden(format!("missing resource: {rt}/{action}"))
}
},
None => match allowed {
Some(actions) if !actions.is_empty() => {}
_ => return AccessResult::Forbidden(format!("missing resource: {rt}")),
},
}
} else if let Some(action) = &self.resource_action {
let found = identity
.resources
.values()
.any(|actions| actions.iter().any(|a| a == action));
if !found {
return AccessResult::Forbidden(format!("missing resource action: {action}"));
}
}
AccessResult::Allowed
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OperationSpec {
pub name: String,
pub namespace: String,
pub op_type: OperationType,
pub visibility: Visibility,
pub input_schema: Value,
pub output_schema: Value,
pub error_schemas: Vec<ErrorDefinition>,
pub access_control: AccessControl,
}
impl OperationSpec {
pub fn new(
name: impl Into<String>,
op_type: OperationType,
visibility: Visibility,
input_schema: Value,
output_schema: Value,
error_schemas: Vec<ErrorDefinition>,
access_control: AccessControl,
) -> Self {
let name = name.into();
let namespace = name
.split('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("")
.to_string();
Self {
name,
namespace,
op_type,
visibility,
input_schema,
output_schema,
error_schemas,
access_control,
}
}
pub fn path(&self) -> String {
format!("/{}", self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn identity(scopes: &[&str], resources: &[(&str, &[&str])]) -> Identity {
let mut res = HashMap::new();
for (k, v) in resources {
res.insert(
(*k).to_string(),
v.iter().map(|s| (*s).to_string()).collect(),
);
}
Identity {
id: "caller".to_string(),
scopes: scopes.iter().map(|s| (*s).to_string()).collect(),
resources: res,
}
}
#[test]
fn path_has_leading_slash() {
let spec = OperationSpec::new(
"fs/readFile",
OperationType::Query,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
);
assert_eq!(spec.path(), "/fs/readFile");
}
#[test]
fn namespace_derived_from_name() {
let spec = OperationSpec::new(
"agent/chat",
OperationType::Subscription,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
);
assert_eq!(spec.namespace, "agent");
assert_eq!(spec.name, "agent/chat");
}
#[test]
fn namespace_for_single_segment() {
let spec = OperationSpec::new(
"list",
OperationType::Query,
Visibility::Internal,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
);
assert_eq!(spec.namespace, "list");
}
#[test]
fn empty_access_control_allowed_for_all() {
let acl = AccessControl::default();
assert_eq!(acl.check(None), AccessResult::Allowed);
let id = identity(&[], &[]);
assert_eq!(acl.check(Some(&id)), AccessResult::Allowed);
}
#[test]
fn none_identity_with_restrictions_forbidden() {
let acl = AccessControl {
required_scopes: vec!["read".to_string()],
..Default::default()
};
assert_eq!(
acl.check(None),
AccessResult::Forbidden("authentication required".to_string())
);
let acl2 = AccessControl {
required_scopes_any: Some(vec!["read".to_string()]),
..Default::default()
};
assert_eq!(
acl2.check(None),
AccessResult::Forbidden("authentication required".to_string())
);
let acl3 = AccessControl {
resource_type: Some("service".to_string()),
..Default::default()
};
assert_eq!(
acl3.check(None),
AccessResult::Forbidden("authentication required".to_string())
);
}
#[test]
fn required_scopes_and_checked() {
let acl = AccessControl {
required_scopes: vec!["a".to_string(), "b".to_string()],
..Default::default()
};
let id_missing = identity(&["a"], &[]);
assert!(matches!(
acl.check(Some(&id_missing)),
AccessResult::Forbidden(_)
));
let id_ok = identity(&["a", "b", "c"], &[]);
assert_eq!(acl.check(Some(&id_ok)), AccessResult::Allowed);
}
#[test]
fn required_scopes_any_or_checked() {
let acl = AccessControl {
required_scopes_any: Some(vec!["x".to_string(), "y".to_string()]),
..Default::default()
};
let id_x = identity(&["x"], &[]);
assert_eq!(acl.check(Some(&id_x)), AccessResult::Allowed);
let id_y = identity(&["y"], &[]);
assert_eq!(acl.check(Some(&id_y)), AccessResult::Allowed);
let id_none = identity(&["z"], &[]);
assert!(matches!(
acl.check(Some(&id_none)),
AccessResult::Forbidden(_)
));
}
#[test]
fn resource_check_with_type_and_action() {
let acl = AccessControl {
resource_type: Some("service".to_string()),
resource_action: Some("read".to_string()),
..Default::default()
};
let id_ok = identity(&[], &[("service", &["read"])]);
assert_eq!(acl.check(Some(&id_ok)), AccessResult::Allowed);
let id_missing_action = identity(&[], &[("service", &["write"])]);
assert!(matches!(
acl.check(Some(&id_missing_action)),
AccessResult::Forbidden(_)
));
let id_missing_type = identity(&[], &[("other", &["read"])]);
assert!(matches!(
acl.check(Some(&id_missing_type)),
AccessResult::Forbidden(_)
));
}
#[test]
fn combined_scopes_and_resources() {
let acl = AccessControl {
required_scopes: vec!["admin".to_string()],
resource_type: Some("service".to_string()),
resource_action: Some("read".to_string()),
..Default::default()
};
let id_ok = identity(&["admin"], &[("service", &["read"])]);
assert_eq!(acl.check(Some(&id_ok)), AccessResult::Allowed);
let id_missing_scope = identity(&["user"], &[("service", &["read"])]);
assert!(matches!(
acl.check(Some(&id_missing_scope)),
AccessResult::Forbidden(_)
));
}
}

View File

@@ -0,0 +1,329 @@
//! Integration test: two-node `alknet/call` round-trip over a real QUIC
//! loopback. A `CallAdapter` server accepts, a `CallClient` connects, and
//! the client calls back into the server (connection symmetry, ADR-017 §2).
//! Verifies the shared dispatch loop works end-to-end.
#![cfg(feature = "quinn")]
use std::sync::Arc;
use std::time::Duration;
use alknet_call::client::{CallClient, CallCredentials, RemoteIdentity};
use alknet_call::protocol::adapter::CallAdapter;
use alknet_call::protocol::wire::ResponseEnvelope;
use alknet_call::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, Handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{Identity, IdentityProvider};
use alknet_core::types::{Capabilities, Connection, ProtocolHandler};
struct NoopIdentityProvider;
impl IdentityProvider for NoopIdentityProvider {
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, _: &alknet_core::auth::AuthToken) -> Option<Identity> {
None
}
}
fn external_spec(name: &str) -> OperationSpec {
OperationSpec::new(
name,
OperationType::Query,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
AccessControl::default(),
)
}
fn echo_handler() -> Handler {
make_handler(|input, context| async move { ResponseEnvelope::ok(context.request_id, input) })
}
/// Build a raw quinn server endpoint with a self-signed cert and the
/// `CallAdapter` accepting `alknet/call` connections. Returns
/// `(bound_addr, server_fingerprint, join_handle)` — the fingerprint is the
/// `SHA256:<hex>` of the self-signed cert DER, which the client pins via
/// `CallCredentials::with_remote_identity` (the known-peer path, ADR-034 §3).
/// The accept loop spawns a task per connection that hands the connection to
/// `CallAdapter::handle`.
async fn build_raw_quinn_server(
registry: Arc<OperationRegistry>,
) -> (std::net::SocketAddr, String, tokio::task::JoinHandle<()>) {
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdentityProvider);
let adapter = Arc::new(CallAdapter::new(
Arc::clone(&registry),
Arc::clone(&provider),
));
let key_pair = rcgen::KeyPair::generate().expect("key gen");
let params = rcgen::CertificateParams::default();
let cert = params.self_signed(&key_pair).expect("self-signed cert");
let cert_der = cert.der().clone();
let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref())
.expect("cert produces fingerprint");
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
);
let provider_crypto = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let mut server_config = rustls::ServerConfig::builder_with_provider(provider_crypto)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![cert_der], key_der)
.unwrap();
server_config.alpn_protocols = vec![b"alknet/call".to_vec()];
server_config.max_early_data_size = u32::MAX;
let quic_server_config =
quinn::crypto::rustls::QuicServerConfig::try_from(server_config).unwrap();
let quinn_server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_server_config));
let quinn_endpoint =
quinn::Endpoint::server(quinn_server_config, "127.0.0.1:0".parse().unwrap())
.expect("server bind");
let bound_addr = quinn_endpoint.local_addr().expect("local addr");
let join = tokio::spawn(async move {
while let Some(incoming) = quinn_endpoint.accept().await {
let adapter = Arc::clone(&adapter);
tokio::spawn(async move {
let connecting = match incoming.accept() {
Ok(c) => c,
Err(_) => return,
};
let conn = match connecting.await {
Ok(c) => c,
Err(_) => return,
};
let alpn = b"alknet/call".to_vec();
let conn = Connection::from_quinn_with_alpn(conn, alpn.clone());
let auth = alknet_core::auth::AuthContext {
identity: None,
alpn,
remote_addr: conn.remote_addr(),
tls_client_fingerprint: None,
};
let _ = adapter.handle(conn, &auth).await;
});
}
});
(bound_addr, fingerprint, join)
}
/// Build the server's registry: an echo op, a secret op, and the
/// services/list + services/schema discovery handlers.
fn build_server_registry() -> Arc<OperationRegistry> {
let mut registry = OperationRegistry::new();
registry
.register(HandlerRegistration::new(
external_spec("server/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
registry
.register(HandlerRegistration::new(
external_spec("server/secret"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "server-secret".to_string()),
))
.unwrap();
let discovery_registry = Arc::new(registry);
let list_handler = services_list_handler(Arc::clone(&discovery_registry));
let schema_handler = services_schema_handler(Arc::clone(&discovery_registry));
let mut full = OperationRegistry::new();
full.register(HandlerRegistration::new(
external_spec("server/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
full.register(HandlerRegistration::new(
external_spec("server/secret"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new().with_api_key("google", "server-secret".to_string()),
))
.unwrap();
full.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(list_handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
full.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(schema_handler),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
Arc::new(full)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_node_call_round_trip() {
let server_registry = build_server_registry();
let (server_addr, server_fingerprint, _server_join) =
build_raw_quinn_server(Arc::clone(&server_registry)).await;
// Client side: a CallClient with its own ops so the server can call back
// (connection symmetry). Pin the server's self-signed cert fingerprint
// (the known-peer path, ADR-034 §3) — `WebPkiServerVerifier` would reject
// it as UnknownIssuer since the self-signed cert is not in the platform
// root store.
let mut client_registry = OperationRegistry::new();
client_registry
.register(HandlerRegistration::new(
external_spec("client/echo"),
HandlerKind::Once(echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
let client_registry = Arc::new(client_registry);
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity {
fingerprint: server_fingerprint,
});
let conn = tokio::time::timeout(
Duration::from_secs(5),
client.connect(server_addr, credentials),
)
.await
.expect("connect did not time out")
.expect("connect succeeds");
// Outbound call: client -> server's echo op.
let response = tokio::time::timeout(
Duration::from_secs(5),
conn.call("server/echo", serde_json::json!({"hi": 1})),
)
.await
.expect("call did not time out");
assert_eq!(response.result, Ok(serde_json::json!({"hi": 1})));
// Peer authorization is enforced by the AccessControl gate in
// OperationRegistry::invoke (ADR-029 §3) — exercised by the unit tests in
// `registry/registration.rs`. This integration test focuses on the QUIC
// connect path + shared dispatch loop working end-to-end (the call above
// proves the CallClient opened a real connection, the shared loop
// dispatched, and the CallConnection::call() round-tripped).
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn from_call_discovers_and_forwards_over_quic_loopback() {
use alknet_call::client::{from_call, FromCallConfig};
use alknet_call::registry::context::ScopedPeerEnv;
let server_registry = build_server_registry();
let (server_addr, server_fingerprint, _server_join) =
build_raw_quinn_server(Arc::clone(&server_registry)).await;
// Client with an empty registry — from_call will populate its overlay.
// Pin the server's self-signed cert fingerprint (ADR-034 §3 known-peer
// path).
let client_registry = Arc::new(OperationRegistry::new());
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity {
fingerprint: server_fingerprint,
});
let conn = tokio::time::timeout(
Duration::from_secs(5),
client.connect(server_addr, credentials),
)
.await
.expect("connect did not time out")
.expect("connect succeeds");
// from_call discovers the server's External ops (server/echo, server/secret
// — both External; services/list + services/schema themselves are External
// too) and builds FromCall forwarding-handler bundles. Register them in the
// connection's Layer 2 overlay.
let bundles = tokio::time::timeout(
Duration::from_secs(5),
from_call(&conn, FromCallConfig::new()),
)
.await
.expect("from_call did not time out")
.expect("from_call succeeds");
assert!(
!bundles.is_empty(),
"from_call must discover at least the server/echo op"
);
conn.register_imported_all(bundles);
// The overlay now contains the discovered ops. Verify the forwarding path
// by invoking the overlay env directly with a scoped context that allows
// server/echo — this is how a composing handler would call the imported op.
let env = conn.overlay_env();
assert!(
env.contains("server/echo"),
"overlay must contain the imported server/echo op"
);
// Build a minimal parent context to invoke the overlay env (mirrors how a
// composing handler dispatches a child).
let scoped = ScopedPeerEnv::new(["server/echo"]);
let parent = alknet_call::registry::context::OperationContext {
request_id: "parent-1".to_string(),
parent_request_id: None,
identity: None,
handler_identity: None,
forwarded_for: None,
capabilities: Capabilities::new(),
metadata: Default::default(),
scoped_env: scoped,
env: env.clone(),
abort_policy: alknet_call::registry::context::AbortPolicy::default(),
deadline: Some(std::time::Instant::now() + Duration::from_secs(30)),
internal: true,
};
let response = tokio::time::timeout(
Duration::from_secs(5),
env.invoke(
"server",
"echo",
serde_json::json!({"from_call": true}),
&parent,
),
)
.await
.expect("overlay invoke did not time out");
assert_eq!(
response.result,
Ok(serde_json::json!({"from_call": true})),
"from_call forwarding handler must round-trip the input to the remote op"
);
}

View File

@@ -3,54 +3,41 @@ name = "alknet-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Core library for Alknet: pluggable SSH tunnel transport, SOCKS5 proxy, port forwarding, and authentication"
description = "Core library for ALPN-based protocol dispatch: ProtocolHandler trait, Connection, auth, config, and multi-connectivity endpoint"
repository.workspace = true
[lib]
name = "alknet_core"
[features]
default = []
tls = ["dep:tokio-rustls", "dep:rustls", "dep:rustls-pki-types", "dep:webpki-roots"]
iroh = ["dep:iroh", "dep:url"]
acme = ["dep:rustls-acme", "dep:futures", "tls"]
http = ["dep:axum", "dep:hyper", "dep:hyper-util", "dep:tower", "dep:http-body-util"]
irpc = []
testutil = []
transport-traits = []
default = ["quinn"]
quinn = ["dep:quinn"]
iroh = ["dep:iroh"]
acme = ["dep:rustls-acme"]
[dependencies]
russh = "0.49"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
anyhow = "1"
thiserror = "2"
tokio-util = { version = "0.7", features = ["compat"] }
tokio-rustls = { version = "0.26", optional = true }
rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] }
rustls-pki-types = { version = "1", optional = true }
rustls-acme = { version = "0.12", optional = true }
futures = { version = "0.3", optional = true }
webpki-roots = { version = "0.26", optional = true }
iroh = { version = "0.34", optional = true }
url = { version = "2", optional = true }
async-trait = "0.1"
ipnetwork = "0.21.1"
arc-swap = "1"
quinn = { version = "0.11", optional = true }
iroh = { version = "0.35", optional = true }
rustls = "0.23"
rustls-pki-types = "1"
rustls-pemfile = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
arc-swap = "1"
async-trait = "0.1"
tracing = "0.1"
thiserror = "2"
zeroize = { version = "1", features = ["alloc", "derive"] }
bytes = "1"
futures = "0.3"
sha2 = "0.10"
hex = "0.4"
axum = { version = "0.8", optional = true }
hyper = { version = "1", optional = true }
hyper-util = { version = "0.1", features = ["tokio", "server", "service"], optional = true }
tower = { version = "0.5", optional = true }
http-body-util = { version = "0.1", optional = true }
rand = "0.8"
rcgen = "0.13"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rustls-acme = { version = "0.12", optional = true, features = ["aws-lc-rs"] }
[dev-dependencies]
alknet-core = { path = ".", features = ["testutil", "tls", "iroh", "http"] }
tempfile = "3"
rcgen = "0.14"
rand_core = "0.6"
ssh-key = { version = "0.6", features = ["ed25519", "alloc"] }
rand = "0.10.1"

View File

@@ -0,0 +1,612 @@
//! Authentication: `AuthContext`, `Identity`, `IdentityProvider`, `AuthToken`,
//! `ConfigIdentityProvider`.
//!
//! See `docs/architecture/crates/core/auth.md` for the full specification and
//! [ADR-034](../../../docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md)
//! for the three-remote-roles decision.
//!
//! # Three remote roles (ADR-034 §1)
//!
//! The three credential types (`PeerEntry.fingerprints` entries) describe how
//! a *single* `PeerEntry` can be authenticated. Separately, there are three
//! distinct remote roles that must not be conflated:
//!
//! | Role | Identity | alknet peer? | `PeerEntry` on local side? |
//! |------|----------|--------------|----------------------------|
//! | **Public X.509 endpoint** | Domain + CA-issued X.509 | No (local node is a client) | No |
//! | **Transport relay** (iroh's DERP-equivalent) | iroh `NodeId` (Ed25519) | No (infrastructure) | No |
//! | **Hub / hosting node** | Ed25519 raw key **and/or** X.509 | Yes (full peer) | Yes |
//!
//! `PeerEntry` (and the `PeerId` it resolves to) is the model for peers in
//! the call-protocol peer graph (ADR-029) — peers that get a stable logical
//! identity, are addressable via `PeerRef::Specific`, and whose ops land in
//! the peer-keyed overlay. A pure-client connection to a public X.509
//! endpoint (e.g. a third-party API) is **not** in that graph on the client
//! side: no `PeerEntry`, no `PeerId`, no `PeerRef::Specific` routing. The
//! asymmetry is deliberate — a public domain's operator can change hands, so
//! there is no stable logical identity to attach.
//!
//! The hub case is an ordinary `PeerEntry` that happens to expose both an
//! Ed25519 fingerprint (P2P path) and an X.509 fingerprint
//! (`SHA256:<hex>`, WebTransport/HTTPS path) — already supported by
//! `PeerEntry.fingerprints: Vec<String>` (ADR-030).
//!
//! # Client-side verifier selection (ADR-034 §3)
//!
//! The `CallClient` / `from_openapi` / `from_mcp` client-side
//! `ServerCertVerifier` is selected by **whether the local node has a
//! `PeerEntry` for the remote**, not by key type alone:
//!
//! | Local has `PeerEntry` for remote? | Remote cert type | Client verifier |
//! |----------------------------------|------------------|-----------------|
//! | No (public X.509 endpoint) | X.509 | `WebPkiServerVerifier` (CA verification) |
//! | No | Ed25519 raw key | fails closed (no CA to fall back to) |
//! | Yes (hub, Ed25519 path) | Ed25519 raw key | fingerprint match (`ed25519:<hex>`) |
//! | Yes (hub, X.509 path) | X.509 | fingerprint match (`SHA256:<hex>`) |
//!
//! This is the key-type-aware verifier from OQ-29, with the peer-model
//! criterion (ADR-034) made explicit. The client-side verifier selection is
//! a `CallClient` concern (`call/call-client-verifier-selection`), not an
//! `IdentityProvider` concern — `IdentityProvider` is unchanged by ADR-034.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use arc_swap::ArcSwap;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::config::{DynamicConfig, PeerEntry};
use crate::store::StoreError;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Identity {
pub id: String,
pub scopes: Vec<String>,
pub resources: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct AuthToken {
pub raw: Vec<u8>,
}
#[derive(Clone)]
pub struct AuthContext {
pub identity: Option<Identity>,
pub alpn: Vec<u8>,
pub remote_addr: Option<SocketAddr>,
pub tls_client_fingerprint: Option<String>,
}
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
/// Write trait — management path, async (ADR-035). `ConfigIdentityProvider`
/// does NOT implement this (config reload is its write path). A persistence
/// adapter (e.g. `SqliteIdentityProvider` in `alknet-store-sqlite`) does:
/// writes hit the backend, emit a honker `NOTIFY`, and the local `LISTEN`
/// refreshes the in-memory read index.
#[async_trait]
pub trait IdentityStore: IdentityProvider {
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError>;
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError>;
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError>;
}
pub struct ConfigIdentityProvider {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigIdentityProvider {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
Self { dynamic }
}
}
impl IdentityProvider for ConfigIdentityProvider {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
let config = self.dynamic.load();
config.auth.resolve_identity_from_fingerprint(fingerprint)
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
let config = self.dynamic.load();
let token_str = String::from_utf8_lossy(&token.raw);
config.auth.resolve_identity_from_token(&token_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKeyEntry, AuthPolicy, DynamicConfig, PeerEntry, RateLimitConfig};
fn compute_api_key_hash(token: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
format!("sha256:{}", hex::encode(result))
}
fn make_provider(
config: DynamicConfig,
) -> (ConfigIdentityProvider, Arc<ArcSwap<DynamicConfig>>) {
let arc_swap = Arc::new(ArcSwap::new(Arc::new(config)));
let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap));
(provider, arc_swap)
}
fn peer_entry_with_fingerprint(peer_id: &str, fingerprint: &str) -> PeerEntry {
PeerEntry {
peer_id: peer_id.to_string(),
fingerprints: vec![fingerprint.to_string()],
auth_token_hash: None,
scopes: vec!["relay:connect".to_string()],
resources: std::collections::HashMap::new(),
display_name: None,
enabled: true,
}
}
fn config_with_peer_entry(peer_id: &str, fingerprint: &str) -> DynamicConfig {
DynamicConfig {
auth: AuthPolicy {
peers: vec![peer_entry_with_fingerprint(peer_id, fingerprint)],
api_keys: Vec::new(),
},
rate_limits: RateLimitConfig::default(),
}
}
fn config_with_api_key(entry: ApiKeyEntry) -> DynamicConfig {
DynamicConfig {
auth: AuthPolicy {
peers: Vec::new(),
api_keys: vec![entry],
},
rate_limits: RateLimitConfig::default(),
}
}
fn compute_token_hash(token: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
format!("sha256:{}", hex::encode(result))
}
#[test]
fn identity_fields_and_equality() {
let mut resources = HashMap::new();
resources.insert(
"service".to_string(),
vec!["gitea".to_string(), "registry".to_string()],
);
let id = Identity {
id: "SHA256:abc123".to_string(),
scopes: vec!["relay:connect".to_string()],
resources,
};
let id2 = id.clone();
assert_eq!(id, id2);
assert_eq!(id.id, "SHA256:abc123");
}
#[test]
fn auth_token_is_clone() {
let token = AuthToken {
raw: b"alk_test".to_vec(),
};
let cloned = token.clone();
assert_eq!(token.raw, cloned.raw);
}
#[test]
fn auth_context_is_clone() {
let ctx = AuthContext {
identity: None,
alpn: b"alknet/test".to_vec(),
remote_addr: None,
tls_client_fingerprint: None,
};
let cloned = ctx.clone();
assert_eq!(cloned.alpn, b"alknet/test");
assert!(cloned.identity.is_none());
}
#[test]
fn fingerprint_resolution_known_returns_some() {
let (provider, _) = make_provider(config_with_peer_entry("worker-a", "SHA256:abc123"));
let identity = provider
.resolve_from_fingerprint("SHA256:abc123")
.expect("known fingerprint resolves");
assert_eq!(identity.id, "worker-a");
assert_eq!(identity.scopes, vec!["relay:connect".to_string()]);
assert!(identity.resources.is_empty());
}
#[test]
fn fingerprint_resolution_unknown_returns_none() {
let (provider, _) = make_provider(config_with_peer_entry("worker-a", "SHA256:abc123"));
assert!(provider
.resolve_from_fingerprint("SHA256:unknown")
.is_none());
}
#[test]
fn fingerprint_resolution_empty_config_returns_none() {
let (provider, _) = make_provider(DynamicConfig::default());
assert!(provider
.resolve_from_fingerprint("SHA256:anything")
.is_none());
}
#[test]
fn token_resolution_valid_non_expired_returns_some() {
let token_str = "alk_testsecret123";
let hash = compute_api_key_hash(token_str);
let entry = ApiKeyEntry {
prefix: "alk_test".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "test key".to_string(),
expires_at: None,
};
let (provider, _) = make_provider(config_with_api_key(entry));
let token = AuthToken {
raw: token_str.as_bytes().to_vec(),
};
let identity = provider
.resolve_from_token(&token)
.expect("valid non-expired token resolves");
assert_eq!(identity.id, "alk_test");
assert_eq!(identity.scopes, vec!["relay:connect".to_string()]);
}
#[test]
fn token_resolution_expired_returns_none() {
let token_str = "alk_testsecret123";
let hash = compute_api_key_hash(token_str);
let entry = ApiKeyEntry {
prefix: "alk_test".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "expired key".to_string(),
expires_at: Some(1),
};
let (provider, _) = make_provider(config_with_api_key(entry));
let token = AuthToken {
raw: token_str.as_bytes().to_vec(),
};
assert!(provider.resolve_from_token(&token).is_none());
}
#[test]
fn token_resolution_unknown_returns_none() {
let token_str = "alk_testsecret123";
let hash = compute_api_key_hash(token_str);
let entry = ApiKeyEntry {
prefix: "alk_test".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "test key".to_string(),
expires_at: None,
};
let (provider, _) = make_provider(config_with_api_key(entry));
let token = AuthToken {
raw: b"alk_unknown".to_vec(),
};
assert!(provider.resolve_from_token(&token).is_none());
}
#[test]
fn token_resolution_wrong_hash_returns_none() {
let entry = ApiKeyEntry {
prefix: "alk_test".to_string(),
hash: "sha256:deadbeef".to_string(),
scopes: vec!["relay:connect".to_string()],
description: "wrong hash".to_string(),
expires_at: None,
};
let (provider, _) = make_provider(config_with_api_key(entry));
let token = AuthToken {
raw: b"alk_testsecret123".to_vec(),
};
assert!(provider.resolve_from_token(&token).is_none());
}
#[test]
fn token_resolution_non_alk_prefix_returns_none() {
let (provider, _) = make_provider(DynamicConfig::default());
let token = AuthToken {
raw: b"bearer_token".to_vec(),
};
assert!(provider.resolve_from_token(&token).is_none());
}
#[test]
fn config_reload_changes_resolution_immediately() {
let (provider, arc_swap) = make_provider(DynamicConfig::default());
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
let new_config = config_with_peer_entry("worker-a", "SHA256:abc123");
arc_swap.store(Arc::new(new_config));
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
}
#[test]
fn config_reload_removes_fingerprint_access_immediately() {
let (provider, arc_swap) =
make_provider(config_with_peer_entry("worker-a", "SHA256:abc123"));
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
arc_swap.store(Arc::new(DynamicConfig::default()));
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
}
#[test]
fn config_reload_handle_reloads_config() {
use crate::config::ConfigReloadHandle;
let arc_swap = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap));
let handle = ConfigReloadHandle::new(arc_swap);
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
handle.reload(config_with_peer_entry("worker-a", "SHA256:abc123"));
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
}
#[test]
fn config_identity_provider_is_identity_provider_not_store() {
fn assert_provider<T: IdentityProvider>() {}
fn assert_not_store<T>() {}
assert_provider::<ConfigIdentityProvider>();
assert_not_store::<ConfigIdentityProvider>();
}
#[test]
fn token_resolution_via_peer_entry_auth_token_hash_returns_peer_id() {
let token_str = "peer-bearer-secret";
let mut entry = peer_entry_with_fingerprint("worker-a", "SHA256:abc123");
entry.auth_token_hash = Some(compute_token_hash(token_str));
let config = DynamicConfig {
auth: AuthPolicy {
peers: vec![entry],
api_keys: Vec::new(),
},
rate_limits: RateLimitConfig::default(),
};
let (provider, _) = make_provider(config);
let token = AuthToken {
raw: token_str.as_bytes().to_vec(),
};
let identity = provider
.resolve_from_token(&token)
.expect("matching PeerEntry.auth_token_hash resolves");
assert_eq!(identity.id, "worker-a");
assert_eq!(identity.scopes, vec!["relay:connect".to_string()]);
}
#[test]
fn token_resolution_falls_through_to_api_key_when_no_peer_entry_matches() {
let api_token = "alk_test_secret";
let mut entry = peer_entry_with_fingerprint("worker-a", "SHA256:abc123");
entry.auth_token_hash = Some(compute_token_hash("different-token"));
let api_entry = ApiKeyEntry {
prefix: "alk_test".to_string(),
hash: compute_api_key_hash(api_token),
scopes: vec!["admin".to_string()],
description: "fall-through key".to_string(),
expires_at: None,
};
let config = DynamicConfig {
auth: AuthPolicy {
peers: vec![entry],
api_keys: vec![api_entry],
},
rate_limits: RateLimitConfig::default(),
};
let (provider, _) = make_provider(config);
let token = AuthToken {
raw: api_token.as_bytes().to_vec(),
};
let identity = provider
.resolve_from_token(&token)
.expect("api key fall-through resolves");
assert_eq!(identity.id, "alk_test");
assert_eq!(identity.scopes, vec!["admin".to_string()]);
}
#[test]
fn disabled_peer_entry_returns_none_on_fingerprint_resolution() {
let mut entry = peer_entry_with_fingerprint("worker-a", "SHA256:abc123");
entry.enabled = false;
let config = DynamicConfig {
auth: AuthPolicy {
peers: vec![entry],
api_keys: Vec::new(),
},
rate_limits: RateLimitConfig::default(),
};
let (provider, _) = make_provider(config);
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
}
#[test]
fn disabled_peer_entry_returns_none_on_token_resolution() {
let token_str = "peer-bearer-secret";
let mut entry = peer_entry_with_fingerprint("worker-a", "SHA256:abc123");
entry.auth_token_hash = Some(compute_token_hash(token_str));
entry.enabled = false;
let config = DynamicConfig {
auth: AuthPolicy {
peers: vec![entry],
api_keys: Vec::new(),
},
rate_limits: RateLimitConfig::default(),
};
let (provider, _) = make_provider(config);
let token = AuthToken {
raw: token_str.as_bytes().to_vec(),
};
assert!(provider.resolve_from_token(&token).is_none());
}
}
#[cfg(test)]
mod identity_store_tests {
use super::*;
use crate::config::PeerEntry;
use std::collections::HashMap as StdHashMap;
use std::sync::RwLock;
fn make_peer(peer_id: &str) -> PeerEntry {
PeerEntry {
peer_id: peer_id.to_string(),
fingerprints: vec![format!("SHA256:{peer_id}")],
auth_token_hash: None,
scopes: vec!["relay:connect".to_string()],
resources: StdHashMap::new(),
display_name: None,
enabled: true,
}
}
struct MockIdentityStore {
peers: RwLock<HashMap<String, PeerEntry>>,
}
impl MockIdentityStore {
fn new() -> Self {
Self {
peers: RwLock::new(HashMap::new()),
}
}
}
impl IdentityProvider for MockIdentityStore {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
let peers = self.peers.read().unwrap_or_else(|e| e.into_inner());
peers.values().find_map(|p| {
if p.fingerprints.iter().any(|f| f == fingerprint) && p.enabled {
Some(Identity {
id: p.peer_id.clone(),
scopes: p.scopes.clone(),
resources: p.resources.clone(),
})
} else {
None
}
})
}
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
None
}
}
#[async_trait]
impl IdentityStore for MockIdentityStore {
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError> {
let mut peers = self.peers.write().unwrap_or_else(|e| e.into_inner());
peers.insert(peer.peer_id.clone(), peer.clone());
Ok(())
}
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError> {
let mut peers = self.peers.write().unwrap_or_else(|e| e.into_inner());
if !peers.contains_key(peer_id) {
return Err(StoreError::NotFound {
entity: peer_id.to_string(),
});
}
peers.remove(peer_id);
peers.insert(peer.peer_id.clone(), peer.clone());
Ok(())
}
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError> {
let mut peers = self.peers.write().unwrap_or_else(|e| e.into_inner());
if peers.remove(peer_id).is_none() {
return Err(StoreError::NotFound {
entity: peer_id.to_string(),
});
}
Ok(())
}
}
#[tokio::test]
async fn mock_put_peer_upserts() {
let store = MockIdentityStore::new();
let mut peer = make_peer("worker-a");
store.put_peer(&peer).await.unwrap();
assert_eq!(
store
.resolve_from_fingerprint("SHA256:worker-a")
.unwrap()
.id,
"worker-a"
);
peer.display_name = Some("renamed".to_string());
store.put_peer(&peer).await.unwrap();
let peers = store.peers.read().unwrap_or_else(|e| e.into_inner());
assert_eq!(peers.len(), 1);
assert_eq!(
peers.get("worker-a").unwrap().display_name.as_deref(),
Some("renamed")
);
}
#[tokio::test]
async fn mock_update_peer_existing_succeeds() {
let store = MockIdentityStore::new();
store.put_peer(&make_peer("worker-a")).await.unwrap();
let updated = make_peer("worker-b");
store.update_peer("worker-a", &updated).await.unwrap();
assert!(store.resolve_from_fingerprint("SHA256:worker-a").is_none());
assert!(store.resolve_from_fingerprint("SHA256:worker-b").is_some());
}
#[tokio::test]
async fn mock_update_peer_missing_returns_not_found() {
let store = MockIdentityStore::new();
let err = store
.update_peer("ghost", &make_peer("ghost"))
.await
.unwrap_err();
assert!(matches!(err, StoreError::NotFound { .. }));
}
#[tokio::test]
async fn mock_remove_peer_existing_succeeds() {
let store = MockIdentityStore::new();
store.put_peer(&make_peer("worker-a")).await.unwrap();
store.remove_peer("worker-a").await.unwrap();
assert!(store.resolve_from_fingerprint("SHA256:worker-a").is_none());
}
#[tokio::test]
async fn mock_remove_peer_missing_returns_not_found() {
let store = MockIdentityStore::new();
let err = store.remove_peer("ghost").await.unwrap_err();
assert!(matches!(err, StoreError::NotFound { .. }));
}
#[test]
fn mock_identity_store_is_identity_provider() {
fn assert_provider<T: IdentityProvider>() {}
assert_provider::<MockIdentityStore>();
}
}

View File

@@ -1,262 +0,0 @@
use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::auth::identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
use crate::config::DynamicConfig;
#[derive(Debug, Clone, PartialEq)]
pub enum AuthProtocol {
VerifyPubkey {
fingerprint: String,
key_data: Vec<u8>,
},
VerifyToken {
token_bytes: Vec<u8>,
timestamp: u64,
},
ReloadKeys,
CheckAccess {
identity: Identity,
operation: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum AuthResult {
Ok(Identity),
Denied(String),
}
pub struct AuthServiceImpl {
provider: ConfigIdentityProvider,
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl AuthServiceImpl {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
let provider = ConfigIdentityProvider::new(Arc::clone(&dynamic));
Self { provider, dynamic }
}
pub fn verify_pubkey(&self, fingerprint: &str) -> AuthResult {
match self.provider.resolve_from_fingerprint(fingerprint) {
Some(identity) => AuthResult::Ok(identity),
None => AuthResult::Denied(format!("key not authorized: {}", fingerprint)),
}
}
pub fn verify_token(&self, token: &AuthToken) -> AuthResult {
match self.provider.resolve_from_token(token) {
Some(identity) => AuthResult::Ok(identity),
None => AuthResult::Denied("token verification failed".to_string()),
}
}
pub fn reload_keys(&self) {
self.dynamic.rcu(Arc::clone);
}
pub fn check_access(&self, identity: &Identity, operation: &str) -> AuthResult {
if identity.scopes.iter().any(|s| s == operation) {
AuthResult::Ok(identity.clone())
} else {
AuthResult::Denied(format!(
"identity {} lacks scope: {}",
identity.id, operation
))
}
}
}
impl std::fmt::Debug for AuthServiceImpl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthServiceImpl").finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::keys::KeySource;
use crate::auth::ServerAuthConfig;
use crate::config::AuthPolicy;
use russh::keys::ssh_key::HashAlg;
use russh::keys::PrivateKey;
use std::io::Write;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096";
fn load_key() -> PrivateKey {
russh::keys::decode_secret_key(ED25519_PRIVATE_KEY, None).unwrap()
}
fn make_authorized_keys_file(keys_content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(keys_content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn make_service(keys_content: &str) -> (AuthServiceImpl, Arc<ArcSwap<DynamicConfig>>) {
let f = make_authorized_keys_file(keys_content);
let server_auth =
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
.unwrap();
let auth_policy = AuthPolicy::from_server_auth_config(server_auth);
let dynamic = DynamicConfig::new(auth_policy);
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let service = AuthServiceImpl::new(Arc::clone(&arc_swap));
(service, arc_swap)
}
#[test]
fn auth_service_verify_pubkey_valid() {
let (service, _) = make_service(ED25519_PUBLIC_KEY);
let key = load_key().public_key().clone();
let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256));
let result = service.verify_pubkey(&fingerprint);
assert!(matches!(result, AuthResult::Ok(_)));
if let AuthResult::Ok(identity) = result {
assert_eq!(identity.id, fingerprint);
}
}
#[test]
fn auth_service_verify_pubkey_invalid() {
let (service, _) = make_service(ED25519_PUBLIC_KEY);
let result = service.verify_pubkey("SHA256:invalid");
assert!(matches!(result, AuthResult::Denied(_)));
}
#[test]
fn auth_service_verify_pubkey_matches_identity_provider() {
let (service, arc_swap) = make_service(ED25519_PUBLIC_KEY);
let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap));
let key = load_key().public_key().clone();
let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256));
let service_result = service.verify_pubkey(&fingerprint);
let provider_result = provider.resolve_from_fingerprint(&fingerprint);
match service_result {
AuthResult::Ok(identity) => {
assert_eq!(identity, provider_result.unwrap());
}
AuthResult::Denied(_) => {
assert!(provider_result.is_none());
}
}
}
#[test]
fn auth_service_verify_token_returns_denied() {
let (service, _) = make_service(ED25519_PUBLIC_KEY);
let token = AuthToken {
raw: b"test-token".to_vec(),
};
let result = service.verify_token(&token);
assert!(matches!(result, AuthResult::Denied(_)));
}
#[test]
fn auth_service_check_access_granted() {
let (service, _) = make_service(ED25519_PUBLIC_KEY);
let key = load_key().public_key().clone();
let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256));
let identity = Identity {
id: fingerprint,
scopes: vec!["relay:connect".to_string()],
resources: std::collections::HashMap::new(),
};
let result = service.check_access(&identity, "relay:connect");
assert!(matches!(result, AuthResult::Ok(_)));
}
#[test]
fn auth_service_check_access_denied() {
let (service, _) = make_service(ED25519_PUBLIC_KEY);
let identity = Identity {
id: "test".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: std::collections::HashMap::new(),
};
let result = service.check_access(&identity, "admin:write");
assert!(matches!(result, AuthResult::Denied(_)));
}
#[test]
fn auth_protocol_variants() {
let identity = Identity {
id: "SHA256:abc".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: std::collections::HashMap::new(),
};
let verify_pubkey = AuthProtocol::VerifyPubkey {
fingerprint: "SHA256:abc".to_string(),
key_data: vec![1, 2, 3],
};
match &verify_pubkey {
AuthProtocol::VerifyPubkey {
fingerprint,
key_data,
} => {
assert_eq!(fingerprint, "SHA256:abc");
assert_eq!(key_data, &vec![1, 2, 3]);
}
_ => panic!("expected VerifyPubkey variant"),
}
let verify_token = AuthProtocol::VerifyToken {
token_bytes: vec![4, 5, 6],
timestamp: 12345,
};
match &verify_token {
AuthProtocol::VerifyToken {
token_bytes,
timestamp,
} => {
assert_eq!(token_bytes, &vec![4, 5, 6]);
assert_eq!(*timestamp, 12345);
}
_ => panic!("expected VerifyToken variant"),
}
assert!(matches!(AuthProtocol::ReloadKeys, AuthProtocol::ReloadKeys));
let check = AuthProtocol::CheckAccess {
identity: identity.clone(),
operation: "relay:connect".to_string(),
};
match &check {
AuthProtocol::CheckAccess {
identity: id,
operation,
} => {
assert_eq!(id.id, "SHA256:abc");
assert_eq!(operation, "relay:connect");
}
_ => panic!("expected CheckAccess variant"),
}
}
#[test]
fn auth_result_ok_identity() {
let identity = Identity {
id: "test".to_string(),
scopes: vec![],
resources: std::collections::HashMap::new(),
};
let result = AuthResult::Ok(identity.clone());
assert_eq!(result, AuthResult::Ok(identity));
}
#[test]
fn auth_result_denied_message() {
let result = AuthResult::Denied("access denied".to_string());
assert_eq!(result, AuthResult::Denied("access denied".to_string()));
}
}

View File

@@ -1,176 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use russh::client;
use russh::keys::key::PrivateKeyWithHashAlg;
use russh::keys::{PrivateKey, PublicKey};
use crate::auth::keys::KeySource;
use crate::error::ConfigError;
/// Client-side SSH authentication configuration.
///
/// Holds the private key used for SSH authentication and an optional
/// public key override. When no public key is provided, it is derived
/// from the private key.
pub struct ClientAuthConfig {
private_key: Arc<PrivateKey>,
public_key: PublicKey,
}
impl ClientAuthConfig {
/// Load a `ClientAuthConfig` from a key source (file or in-memory).
pub fn from_key_source(source: KeySource) -> Result<Self, ConfigError> {
let private_key = crate::auth::keys::load_private_key(source)?;
let public_key = private_key.public_key().clone();
Ok(Self {
private_key: Arc::new(private_key),
public_key,
})
}
/// Returns the private key wrapped in `Arc` for use with russh authentication.
pub fn private_key(&self) -> Arc<PrivateKey> {
Arc::clone(&self.private_key)
}
/// Returns the public key derived from (or overridden for) this config.
pub fn public_key(&self) -> &PublicKey {
&self.public_key
}
/// Authenticate with the given SSH session handle and username.
pub async fn authenticate<H: client::Handler>(
&self,
handle: &mut client::Handle<H>,
username: &str,
) -> Result<bool, russh::Error> {
let key_with_alg = PrivateKeyWithHashAlg::new(Arc::clone(&self.private_key), None)?;
handle.authenticate_publickey(username, key_with_alg).await
}
}
/// Client handler implementing `russh::client::Handler`.
///
/// Provides the callbacks required by russh during the SSH handshake.
/// Server key verification is delegated to a configurable callback;
/// the default accepts all server keys (suitable for testing or when
/// transport-layer verification — e.g. TLS — is already in place).
pub struct ClientHandler {
pub_key: PublicKey,
check_server_key_fn: Box<dyn Fn(&PublicKey) -> bool + Send + Sync>,
}
impl ClientHandler {
/// Create a new client handler from a `ClientAuthConfig`.
pub fn from_config(config: &ClientAuthConfig) -> Self {
Self {
pub_key: config.public_key().clone(),
check_server_key_fn: Box::new(|_| true),
}
}
/// Create a client handler with a custom server key verification callback.
pub fn with_server_key_check(
config: &ClientAuthConfig,
check_fn: impl Fn(&PublicKey) -> bool + Send + Sync + 'static,
) -> Self {
Self {
pub_key: config.public_key().clone(),
check_server_key_fn: Box::new(check_fn),
}
}
/// Returns the public key associated with this handler.
pub fn public_key(&self) -> &PublicKey {
&self.pub_key
}
}
#[async_trait]
impl client::Handler for ClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &PublicKey,
) -> Result<bool, Self::Error> {
Ok((self.check_server_key_fn)(server_public_key))
}
}
#[cfg(test)]
mod tests {
use super::*;
use russh::client::Handler;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
#[test]
fn from_key_source_memory() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let config = ClientAuthConfig::from_key_source(source).unwrap();
assert_eq!(
config.public_key().algorithm(),
russh::keys::Algorithm::Ed25519
);
}
#[test]
fn handler_from_config() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let config = ClientAuthConfig::from_key_source(source).unwrap();
let handler = ClientHandler::from_config(&config);
assert_eq!(
handler.public_key().algorithm(),
russh::keys::Algorithm::Ed25519
);
}
#[test]
fn handler_with_custom_server_key_check() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let config = ClientAuthConfig::from_key_source(source).unwrap();
let handler = ClientHandler::with_server_key_check(&config, |_pk| false);
assert_eq!(
handler.public_key().algorithm(),
russh::keys::Algorithm::Ed25519
);
}
#[test]
fn from_key_source_invalid_key() {
let source = KeySource::Memory(b"not a key".to_vec());
let result = ClientAuthConfig::from_key_source(source);
assert!(result.is_err());
}
#[tokio::test]
async fn handler_check_server_key_accepts_by_default() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let config = ClientAuthConfig::from_key_source(source).unwrap();
let mut handler = ClientHandler::from_config(&config);
let some_key = config.public_key().clone();
let result = handler.check_server_key(&some_key).await.unwrap();
assert!(result);
}
#[tokio::test]
async fn handler_check_server_key_rejects_with_custom_fn() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let config = ClientAuthConfig::from_key_source(source).unwrap();
let mut handler = ClientHandler::with_server_key_check(&config, |_pk| false);
let some_key = config.public_key().clone();
let result = handler.check_server_key(&some_key).await.unwrap();
assert!(!result);
}
#[test]
fn private_key_arc_dedup() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let config = ClientAuthConfig::from_key_source(source).unwrap();
let key1 = config.private_key();
let key2 = config.private_key();
assert!(Arc::ptr_eq(&key1, &key2));
}
}

View File

@@ -1,349 +0,0 @@
//! Identity resolution and the `IdentityProvider` trait.
//!
//! See [ADR-029](docs/architecture/decisions/029-identity-provider.md) and
//! [ADR-028](docs/architecture/decisions/028-identity-model.md).
use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::config::DynamicConfig;
#[derive(Debug, Clone, PartialEq)]
pub struct Identity {
pub id: String,
pub scopes: Vec<String>,
pub resources: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct AuthToken {
pub raw: Vec<u8>,
}
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
pub struct ConfigIdentityProvider {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigIdentityProvider {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
Self { dynamic }
}
}
impl IdentityProvider for ConfigIdentityProvider {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
let config = self.dynamic.load();
let auth = &config.auth;
auth.resolve_identity_from_fingerprint(fingerprint)
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
let config = self.dynamic.load();
let auth = &config.auth;
let token_str = String::from_utf8_lossy(&token.raw);
if token_str.starts_with(crate::config::API_KEY_PREFIX) {
return auth.resolve_api_key(&token_str);
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::keys::KeySource;
use crate::auth::ServerAuthConfig;
use crate::config::AuthPolicy;
use russh::keys::ssh_key::HashAlg;
use russh::keys::PrivateKey;
use std::io::Write;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096";
fn load_key() -> PrivateKey {
russh::keys::decode_secret_key(ED25519_PRIVATE_KEY, None).unwrap()
}
fn make_authorized_keys_file(keys_content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(keys_content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn make_provider(keys_content: &str) -> (ConfigIdentityProvider, Arc<ArcSwap<DynamicConfig>>) {
let f = make_authorized_keys_file(keys_content);
let server_auth =
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
.unwrap();
let auth_policy = AuthPolicy::from_server_auth_config(server_auth);
let dynamic = DynamicConfig::new(auth_policy);
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap));
(provider, arc_swap)
}
#[test]
fn identity_fields() {
let mut resources = HashMap::new();
resources.insert(
"service".to_string(),
vec!["gitea".to_string(), "registry".to_string()],
);
let identity = Identity {
id: "SHA256:abc123".to_string(),
scopes: vec![
"relay:connect".to_string(),
"service:gitea:read".to_string(),
],
resources,
};
assert_eq!(identity.id, "SHA256:abc123");
assert_eq!(identity.scopes, vec!["relay:connect", "service:gitea:read"]);
assert_eq!(
identity.resources.get("service").unwrap(),
&vec!["gitea".to_string(), "registry".to_string()]
);
}
#[test]
fn identity_equality() {
let id1 = Identity {
id: "test".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
let id2 = Identity {
id: "test".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
assert_eq!(id1, id2);
}
#[test]
fn identity_inequality_different_id() {
let id1 = Identity {
id: "a".to_string(),
scopes: vec![],
resources: HashMap::new(),
};
let id2 = Identity {
id: "b".to_string(),
scopes: vec![],
resources: HashMap::new(),
};
assert_ne!(id1, id2);
}
#[test]
fn config_identity_provider_resolves_valid_fingerprint() {
let (provider, _) = make_provider(ED25519_PUBLIC_KEY);
let key = load_key().public_key().clone();
let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256));
let identity = provider.resolve_from_fingerprint(&fingerprint);
assert!(identity.is_some());
let identity = identity.unwrap();
assert_eq!(identity.id, fingerprint);
assert!(!identity.scopes.is_empty());
}
#[test]
fn config_identity_provider_rejects_invalid_fingerprint() {
let (provider, _) = make_provider(ED25519_PUBLIC_KEY);
let identity = provider.resolve_from_fingerprint("SHA256:invalid");
assert!(identity.is_none());
}
#[test]
fn config_identity_provider_empty_config_rejects_all() {
let dynamic = DynamicConfig::default();
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let provider = ConfigIdentityProvider::new(arc_swap);
let identity = provider.resolve_from_fingerprint("SHA256:anything");
assert!(identity.is_none());
}
#[test]
fn config_identity_provider_resolve_from_token_returns_none() {
let (provider, _) = make_provider(ED25519_PUBLIC_KEY);
let token = AuthToken {
raw: b"test-token".to_vec(),
};
assert!(provider.resolve_from_token(&token).is_none());
}
fn compute_api_key_hash(token: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
format!("sha256:{}", hex::encode(result))
}
#[test]
fn config_identity_provider_resolves_valid_api_key() {
let token = "alk_testsecret123";
let hash = compute_api_key_hash(token);
let entry = crate::config::ApiKeyEntry {
prefix: "alk_test".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "test key".to_string(),
expires_at: None,
};
let auth_policy = crate::config::AuthPolicy::with_api_keys(
std::collections::HashSet::new(),
Vec::new(),
vec![entry],
);
let dynamic = DynamicConfig::new(auth_policy);
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let provider = ConfigIdentityProvider::new(arc_swap);
let auth_token = AuthToken {
raw: token.as_bytes().to_vec(),
};
let identity = provider.resolve_from_token(&auth_token);
assert!(identity.is_some());
let identity = identity.unwrap();
assert_eq!(identity.id, "alk_test");
assert_eq!(identity.scopes, vec!["relay:connect"]);
}
#[test]
fn config_identity_provider_rejects_expired_api_key() {
let token = "alk_expiredkey1";
let hash = compute_api_key_hash(token);
let entry = crate::config::ApiKeyEntry {
prefix: "alk_expi".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "expired key".to_string(),
expires_at: Some(1),
};
let auth_policy = crate::config::AuthPolicy::with_api_keys(
std::collections::HashSet::new(),
Vec::new(),
vec![entry],
);
let dynamic = DynamicConfig::new(auth_policy);
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let provider = ConfigIdentityProvider::new(arc_swap);
let auth_token = AuthToken {
raw: token.as_bytes().to_vec(),
};
assert!(provider.resolve_from_token(&auth_token).is_none());
}
#[test]
fn config_identity_provider_rejects_wrong_hash_api_key() {
let entry = crate::config::ApiKeyEntry {
prefix: "alk_test".to_string(),
hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
scopes: vec!["relay:connect".to_string()],
description: "bad hash".to_string(),
expires_at: None,
};
let auth_policy = crate::config::AuthPolicy::with_api_keys(
std::collections::HashSet::new(),
Vec::new(),
vec![entry],
);
let dynamic = DynamicConfig::new(auth_policy);
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let provider = ConfigIdentityProvider::new(arc_swap);
let auth_token = AuthToken {
raw: b"alk_testsecret123".to_vec(),
};
assert!(provider.resolve_from_token(&auth_token).is_none());
}
#[test]
fn config_identity_provider_api_key_unknown_prefix_falls_through() {
let token = "alk_testsecret123";
let hash = compute_api_key_hash(token);
let entry = crate::config::ApiKeyEntry {
prefix: "alk_other".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "other key".to_string(),
expires_at: None,
};
let auth_policy = crate::config::AuthPolicy::with_api_keys(
std::collections::HashSet::new(),
Vec::new(),
vec![entry],
);
let dynamic = DynamicConfig::new(auth_policy);
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let provider = ConfigIdentityProvider::new(arc_swap);
let auth_token = AuthToken {
raw: token.as_bytes().to_vec(),
};
assert!(provider.resolve_from_token(&auth_token).is_none());
}
#[test]
fn config_identity_provider_api_key_scopes_in_identity() {
let token = "alk_scopedkey12";
let hash = compute_api_key_hash(token);
let entry = crate::config::ApiKeyEntry {
prefix: "alk_sco".to_string(),
hash,
scopes: vec!["relay:connect".to_string(), "secrets:derive".to_string()],
description: "scoped key".to_string(),
expires_at: None,
};
let auth_policy = crate::config::AuthPolicy::with_api_keys(
std::collections::HashSet::new(),
Vec::new(),
vec![entry],
);
let dynamic = DynamicConfig::new(auth_policy);
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
let provider = ConfigIdentityProvider::new(arc_swap);
let auth_token = AuthToken {
raw: token.as_bytes().to_vec(),
};
let identity = provider.resolve_from_token(&auth_token).unwrap();
assert_eq!(identity.scopes, vec!["relay:connect", "secrets:derive"]);
}
#[test]
fn auth_token_holds_raw_bytes() {
let token = AuthToken { raw: vec![1, 2, 3] };
assert_eq!(token.raw, vec![1, 2, 3]);
}
#[test]
fn config_identity_provider_reflects_config_reload() {
let (provider, arc_swap) = make_provider(ED25519_PUBLIC_KEY);
let key = load_key().public_key().clone();
let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256));
let identity = provider.resolve_from_fingerprint(&fingerprint);
assert!(identity.is_some());
let new_dynamic = DynamicConfig::default();
arc_swap.store(Arc::new(new_dynamic));
let identity = provider.resolve_from_fingerprint(&fingerprint);
assert!(identity.is_none());
}
}

View File

@@ -1,258 +0,0 @@
//! Key loading and parsing for SSH authentication.
//!
//! Supports `KeySource` (file path or in-memory) for private keys, public keys,
//! and certificate authority entries. All keys must be in OpenSSH format.
//! PEM-encoded keys (PKCS#1, PKCS#8) are rejected with a clear error message.
use std::path::PathBuf;
use russh::keys::{decode_secret_key, parse_public_key_base64, PrivateKey, PublicKey};
use crate::error::ConfigError;
/// Source for key material — either a filesystem path or in-memory bytes.
///
/// Used throughout the API to accept keys without committing to a specific
/// loading mechanism. In-memory keys are primarily for the NAPI wrapper.
#[derive(Debug, Clone)]
pub enum KeySource {
File(PathBuf),
Memory(Vec<u8>),
}
/// A certificate authority entry parsed from an `authorized_keys` file.
///
/// Contains the CA public key and its associated options (e.g., `cert-authority`,
/// `permit-port-forwarding`). Used by `ServerAuthConfig` for certificate validation.
#[derive(Debug, Clone)]
pub struct CertAuthorityEntry {
pub public_key: PublicKey,
pub options: Vec<String>,
}
fn resolve_bytes(source: &KeySource) -> Result<Vec<u8>, ConfigError> {
match source {
KeySource::File(path) => {
if !path.exists() {
return Err(ConfigError::KeyFileNotFound {
path: path.display().to_string(),
});
}
std::fs::read(path).map_err(|_| ConfigError::KeyFileNotFound {
path: path.display().to_string(),
})
}
KeySource::Memory(data) => Ok(data.clone()),
}
}
fn check_openssh_private_key(data: &[u8]) -> Result<(), ConfigError> {
let s = String::from_utf8_lossy(data);
if s.contains("-----BEGIN OPENSSH PRIVATE KEY-----") {
return Ok(());
}
if s.contains("-----BEGIN RSA PRIVATE KEY-----")
|| s.contains("-----BEGIN PRIVATE KEY-----")
|| s.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")
|| s.contains("-----BEGIN EC PRIVATE KEY-----")
{
return Err(ConfigError::InvalidFlag {
name: "PEM-encoded key is not supported; use OpenSSH format (-----BEGIN OPENSSH PRIVATE KEY-----)".to_string(),
});
}
Err(ConfigError::InvalidFlag {
name: "unrecognized private key format; expected OpenSSH format (-----BEGIN OPENSSH PRIVATE KEY-----)".to_string(),
})
}
pub fn load_private_key(source: KeySource) -> Result<PrivateKey, ConfigError> {
let data = resolve_bytes(&source)?;
check_openssh_private_key(&data)?;
let s = String::from_utf8_lossy(&data);
decode_secret_key(&s, None).map_err(|e| ConfigError::InvalidFlag {
name: format!("failed to decode private key: {e}"),
})
}
fn parse_authorized_keys_line(line: &str) -> Option<Result<(PublicKey, Vec<String>), ConfigError>> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let parts: Vec<&str> = line.splitn(4, ' ').collect();
if parts.len() < 2 {
return None;
}
let mut options = Vec::new();
let key_type_idx;
if parts[0].starts_with("cert-authority")
|| parts[0].starts_with("no-")
|| parts[0].starts_with("permit-")
|| parts[0].starts_with("from=")
|| parts[0].starts_with("command=")
|| parts[0].starts_with("environment=")
|| parts[0].starts_with("tunnel=")
|| parts[0].starts_with("principals=")
{
let opts_str = parts[0];
options = opts_str.split(',').map(|s| s.to_string()).collect();
key_type_idx = 1;
} else if parts[0].starts_with("ssh-") || parts[0].starts_with("ecdsa-") {
key_type_idx = 0;
} else {
return None;
}
if parts.len() <= key_type_idx {
return None;
}
let key_base64 = parts[key_type_idx + 1];
match parse_public_key_base64(key_base64) {
Ok(pk) => Some(Ok((pk, options))),
Err(_) => None,
}
}
pub fn load_public_keys(source: KeySource) -> Result<Vec<PublicKey>, ConfigError> {
let data = resolve_bytes(&source)?;
let s = String::from_utf8_lossy(&data);
let mut keys = Vec::new();
for line in s.lines() {
if let Some(Ok((pk, _))) = parse_authorized_keys_line(line) {
keys.push(pk);
}
}
Ok(keys)
}
pub fn load_cert_authority_entries(
source: KeySource,
) -> Result<Vec<CertAuthorityEntry>, ConfigError> {
let data = resolve_bytes(&source)?;
let s = String::from_utf8_lossy(&data);
let mut entries = Vec::new();
for line in s.lines() {
if let Some(result) = parse_authorized_keys_line(line) {
match result {
Ok((pk, options)) if !options.is_empty() => {
entries.push(CertAuthorityEntry {
public_key: pk,
options,
});
}
_ => {}
}
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096";
const PEM_PRIVATE_KEY: &[u8] = b"-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEINTuctv5E1hK1bbY8fdp+K06/nwoy/HU++CXqI9EdVhC\n-----END PRIVATE KEY-----\n";
fn make_authorized_keys(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
write!(f, "{content}").unwrap();
f.flush().unwrap();
f
}
fn make_private_key_file(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn load_ed25519_key_from_file() {
let f = make_private_key_file(ED25519_PRIVATE_KEY);
let source = KeySource::File(f.path().to_path_buf());
let key = load_private_key(source).unwrap();
assert_eq!(key.algorithm(), russh::keys::Algorithm::Ed25519);
}
#[test]
fn load_ed25519_key_from_memory() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let key = load_private_key(source).unwrap();
assert_eq!(key.algorithm(), russh::keys::Algorithm::Ed25519);
}
#[test]
fn load_key_file_not_found() {
let source = KeySource::File(PathBuf::from("/nonexistent/key"));
let result = load_private_key(source);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::KeyFileNotFound { .. }));
assert!(err.to_string().contains("/nonexistent/key"));
}
#[test]
fn reject_pem_format() {
let source = KeySource::Memory(PEM_PRIVATE_KEY.to_vec());
let result = load_private_key(source);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::InvalidFlag { .. }));
assert!(err.to_string().contains("PEM"));
}
const ED25519_PUBLIC_KEY_2: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE other@host";
#[test]
fn parse_authorized_keys_multiple_entries() {
let content = format!("{ED25519_PUBLIC_KEY}\n# comment line\n\n{ED25519_PUBLIC_KEY_2}\n");
let f = make_authorized_keys(&content);
let source = KeySource::File(f.path().to_path_buf());
let keys = load_public_keys(source).unwrap();
assert_eq!(keys.len(), 2);
}
#[test]
fn parse_authorized_keys_from_memory() {
let content = format!("{ED25519_PUBLIC_KEY}\n");
let source = KeySource::Memory(content.into_bytes());
let keys = load_public_keys(source).unwrap();
assert_eq!(keys.len(), 1);
}
#[test]
fn parse_cert_authority_entry() {
let content =
"cert-authority,permit-port-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV CA name\n";
let f = make_authorized_keys(content);
let source = KeySource::File(f.path().to_path_buf());
let entries = load_cert_authority_entries(source).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].options.len(), 2);
assert_eq!(entries[0].options[0], "cert-authority");
assert_eq!(entries[0].options[1], "permit-port-forwarding");
}
#[test]
fn parse_mixed_authorized_keys() {
let content = format!(
"{ED25519_PUBLIC_KEY}\ncert-authority ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE CA name\n"
);
let source = KeySource::Memory(content.into_bytes());
let keys = load_public_keys(source.clone()).unwrap();
assert_eq!(keys.len(), 2);
let entries = load_cert_authority_entries(source).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].options, vec!["cert-authority"]);
}
}

View File

@@ -1,18 +0,0 @@
//! SSH authentication (Ed25519 public key and OpenSSH certificate authority).
//!
//! Supports file-path and in-memory key sources. No password authentication.
//! See ADR-012 for the design rationale.
#[cfg(feature = "irpc")]
pub mod auth_protocol;
pub mod client_auth;
pub mod identity;
pub mod keys;
pub mod server_auth;
#[cfg(feature = "irpc")]
pub use auth_protocol::{AuthProtocol, AuthResult, AuthServiceImpl};
pub use client_auth::{ClientAuthConfig, ClientHandler};
pub use identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
pub use keys::{load_private_key, load_public_keys, CertAuthorityEntry, KeySource};
pub use server_auth::ServerAuthConfig;

View File

@@ -1,395 +0,0 @@
//! Server-side authentication configuration and validation.
//!
//! `ServerAuthConfig` holds the set of authorized public keys and optional certificate
//! authority entries. Authentication is key-based only (Ed25519 + optional OpenSSH CA).
//! No password authentication. See ADR-012.
use std::collections::HashSet;
use std::net::IpAddr;
use std::str::FromStr;
use std::time::SystemTime;
use ipnetwork::IpNetwork;
use russh::keys::helpers::EncodedExt;
use russh::keys::{Certificate, PublicKey};
use super::keys::{load_cert_authority_entries, load_public_keys, CertAuthorityEntry, KeySource};
use crate::error::AuthError;
/// Server-side authentication configuration.
///
/// Holds authorized public keys (constant-time comparison) and optional certificate
/// authority entries for validating OpenSSH certificates.
#[derive(Debug, Clone)]
pub struct ServerAuthConfig {
pub authorized_keys: HashSet<PublicKey>,
pub cert_authorities: Vec<CertAuthorityEntry>,
encoded_keys: HashSet<Vec<u8>>,
}
fn encode_key_data(key: &PublicKey) -> Vec<u8> {
key.key_data().encoded().unwrap_or_default()
}
impl ServerAuthConfig {
pub fn from_keys_and_ca(
authorized_keys_source: Option<KeySource>,
cert_authority_source: Option<KeySource>,
) -> Result<Self, crate::error::ConfigError> {
let authorized_keys: HashSet<PublicKey> = match authorized_keys_source {
Some(src) => load_public_keys(src)?.into_iter().collect(),
None => HashSet::new(),
};
let encoded_keys: HashSet<Vec<u8>> = authorized_keys.iter().map(encode_key_data).collect();
let cert_authorities = match cert_authority_source {
Some(src) => load_cert_authority_entries(src)?,
None => Vec::new(),
};
Ok(ServerAuthConfig {
authorized_keys,
cert_authorities,
encoded_keys,
})
}
pub fn authenticate_publickey(&self, key: &PublicKey) -> Result<(), AuthError> {
let encoded = encode_key_data(key);
if self.encoded_keys.contains(&encoded) {
return Ok(());
}
Err(AuthError::KeyRejected)
}
pub fn authenticate_certificate(
&self,
cert: &Certificate,
user: &str,
client_ip: Option<IpAddr>,
) -> Result<(), AuthError> {
let matching_ca = self
.cert_authorities
.iter()
.find(|ca| cert.signature_key() == ca.public_key.key_data());
let ca_entry = match matching_ca {
Some(entry) => entry,
None => return Err(AuthError::CertInvalid),
};
if cert.verify_signature().is_err() {
return Err(AuthError::CertInvalid);
}
let now = SystemTime::now();
let now_secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now_secs < cert.valid_after() || now_secs >= cert.valid_before() {
return Err(AuthError::CertExpired);
}
let principals = cert.valid_principals();
if !principals.is_empty() && !principals.iter().any(|p| p == user) {
return Err(AuthError::CertPrincipalMismatch);
}
check_critical_options(cert, ca_entry, client_ip)?;
check_extensions(cert, ca_entry)?;
Ok(())
}
}
fn check_critical_options(
cert: &Certificate,
ca_entry: &CertAuthorityEntry,
client_ip: Option<IpAddr>,
) -> Result<(), AuthError> {
let ca_has_no_pty = ca_entry.options.iter().any(|o| o == "no-pty");
for (name, data) in cert.critical_options().iter() {
match name.as_str() {
"source-address" => {
if !check_source_address(data, client_ip) {
return Err(AuthError::CertInvalid);
}
}
"force-command" => {}
"no-pty" => {}
_ => {
let _ = ca_has_no_pty;
return Err(AuthError::CertInvalid);
}
}
}
Ok(())
}
fn check_extensions(cert: &Certificate, ca_entry: &CertAuthorityEntry) -> Result<(), AuthError> {
let ca_permit_port_forwarding = ca_entry
.options
.iter()
.any(|o| o == "permit-port-forwarding");
if ca_permit_port_forwarding {
let cert_allows = cert
.extensions()
.iter()
.any(|(n, _)| n == "permit-port-forwarding");
if !cert_allows {
return Err(AuthError::CertInvalid);
}
}
Ok(())
}
fn check_source_address(allowed: &str, client_ip: Option<IpAddr>) -> bool {
let Some(ip) = client_ip else {
return false;
};
for pattern in allowed.split(',') {
let pattern = pattern.trim();
if pattern.is_empty() {
continue;
}
if let Ok(cidr) = IpNetwork::from_str(pattern) {
if cidr.contains(ip) {
return true;
}
}
if let Ok(net_ip) = IpAddr::from_str(pattern) {
if net_ip == ip {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use rand_core::OsRng;
use russh::keys::ssh_key::certificate::{Builder, CertType};
use russh::keys::{decode_secret_key, Certificate, PrivateKey};
use std::io::Write;
const CA_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACA6pFKBI327JsRFmZULalNjpoUPJMVxzsk9bGbDByat+gAAAJjP22Bpz9tg\naQAAAAtzc2gtZWQyNTUxOQAAACA6pFKBI327JsRFmZULalNjpoUPJMVxzsk9bGbDByat+g\nAAAEBcRrWyUU+lLpjHbaaYN5YeOlvz6HnuBndUWevEmHk00jqkUoEjfbsmxEWZlQtqU2Om\nhQ8kxXHOyT1sZsMHJq36AAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const USER_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACAoTr8X7HqltuKBdBdB2Vjb+K7bi3vVPcuWAYIb3ur5NgAAAJgM/+f3DP/n\n9wAAAAtzc2gtZWQyNTUxOQAAACAoTr8X7HqltuKBdBdB2Vjb+K7bi3vVPcuWAYIb3ur5Ng\nAAAEADN/ZEFvX/mflX8aEGwS/tMzys564rYEaMzd4vmYKZkShOvxfseqW24oF0F0HZWNv4\nrtuLe9U9y5YBghve6vk2AAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const OTHER_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACC/7V2LLT4WRm1Mfje8eSPWlhN+kNXz2ryKoqCkSrGzdgAAAJgXj2UzF49l\nMwAAAAtzc2gtZWQyNTUxOQAAACC/7V2LLT4WRm1Mfje8eSPWlhN+kNXz2ryKoqCkSrGzdg\nAAAEBVadyi5nAUfkjpp4zyQ08b8h1o4RTEgwtLejTjX5Tycb/tXYstPhZGbUx+N7x5I9aW\nE36Q1fPavIqioKRKsbN2AAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
fn load_ca_key() -> PrivateKey {
decode_secret_key(CA_PRIVATE_KEY, None).unwrap()
}
fn load_user_key() -> PrivateKey {
decode_secret_key(USER_PRIVATE_KEY, None).unwrap()
}
fn load_other_key() -> PrivateKey {
decode_secret_key(OTHER_PRIVATE_KEY, None).unwrap()
}
fn make_cert(
ca_key: &PrivateKey,
user_pub: &PublicKey,
valid_after: u64,
valid_before: u64,
principals: Vec<&str>,
) -> Certificate {
let key_data: russh::keys::ssh_key::public::KeyData = user_pub.into();
let mut builder =
Builder::new_with_random_nonce(&mut OsRng, key_data, valid_after, valid_before)
.unwrap();
builder.cert_type(CertType::User).unwrap();
for p in principals {
builder.valid_principal(p).unwrap();
}
builder.sign(ca_key).unwrap()
}
fn make_authorized_keys_file(keys: &[&PublicKey]) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
for key in keys {
let line = format!("{}\n", key.to_openssh().unwrap());
f.write_all(line.as_bytes()).unwrap();
}
f.flush().unwrap();
f
}
fn make_ca_file(ca_pub: &PublicKey, options: &[&str]) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
let opts = if options.is_empty() {
"cert-authority".to_string()
} else {
format!("cert-authority,{}", options.join(","))
};
let line = format!("{} {} CA\n", opts, ca_pub.to_openssh().unwrap());
f.write_all(line.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[test]
fn valid_key_accepted() {
let user_key = load_user_key();
let user_pub = user_key.public_key().clone();
let f = make_authorized_keys_file(&[&user_pub]);
let config =
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
.unwrap();
assert!(config.authenticate_publickey(&user_pub).is_ok());
}
#[test]
fn invalid_key_rejected() {
let user_key = load_user_key();
let other_key = load_other_key();
let user_pub = user_key.public_key().clone();
let other_pub = other_key.public_key().clone();
let f = make_authorized_keys_file(&[&user_pub]);
let config =
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
.unwrap();
assert_eq!(
config.authenticate_publickey(&other_pub),
Err(AuthError::KeyRejected)
);
}
#[test]
fn cert_authority_signed_cert_accepted() {
let ca_key = load_ca_key();
let user_key = load_user_key();
let ca_pub = ca_key.public_key().clone();
let user_pub = user_key.public_key().clone();
let now = now_secs();
let cert = make_cert(&ca_key, &user_pub, now - 60, now + 3600, vec!["testuser"]);
let f = make_ca_file(&ca_pub, &[]);
let config =
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
.unwrap();
assert!(config
.authenticate_certificate(&cert, "testuser", None)
.is_ok());
}
#[test]
fn expired_cert_rejected() {
let ca_key = load_ca_key();
let user_key = load_user_key();
let ca_pub = ca_key.public_key().clone();
let user_pub = user_key.public_key().clone();
let now = now_secs();
let cert = make_cert(&ca_key, &user_pub, now - 7200, now - 3600, vec!["testuser"]);
let f = make_ca_file(&ca_pub, &[]);
let config =
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
.unwrap();
assert_eq!(
config.authenticate_certificate(&cert, "testuser", None),
Err(AuthError::CertExpired)
);
}
#[test]
fn wrong_principal_rejected() {
let ca_key = load_ca_key();
let user_key = load_user_key();
let ca_pub = ca_key.public_key().clone();
let user_pub = user_key.public_key().clone();
let now = now_secs();
let cert = make_cert(&ca_key, &user_pub, now - 60, now + 3600, vec!["alice"]);
let f = make_ca_file(&ca_pub, &[]);
let config =
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
.unwrap();
assert_eq!(
config.authenticate_certificate(&cert, "bob", None),
Err(AuthError::CertPrincipalMismatch)
);
}
#[test]
fn cert_wildcard_principals_accepts_any_user() {
let ca_key = load_ca_key();
let user_key = load_user_key();
let ca_pub = ca_key.public_key().clone();
let user_pub = user_key.public_key().clone();
let now = now_secs();
let key_data: russh::keys::ssh_key::public::KeyData = (&user_pub).into();
let mut builder =
Builder::new_with_random_nonce(&mut OsRng, key_data, now - 60, now + 3600).unwrap();
builder.cert_type(CertType::User).unwrap();
builder.all_principals_valid().unwrap();
let cert = builder.sign(&ca_key).unwrap();
let f = make_ca_file(&ca_pub, &[]);
let config =
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
.unwrap();
assert!(config
.authenticate_certificate(&cert, "anyuser", None)
.is_ok());
}
#[test]
fn cert_wrong_ca_rejected() {
let user_key = load_user_key();
let other_ca_key = load_other_key();
let user_pub = user_key.public_key().clone();
let now = now_secs();
let cert = make_cert(
&other_ca_key,
&user_pub,
now - 60,
now + 3600,
vec!["testuser"],
);
let ca_key = load_ca_key();
let ca_pub = ca_key.public_key().clone();
let f = make_ca_file(&ca_pub, &[]);
let config =
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
.unwrap();
assert_eq!(
config.authenticate_certificate(&cert, "testuser", None),
Err(AuthError::CertInvalid)
);
}
#[test]
fn no_config_accepts_nothing() {
let config = ServerAuthConfig::from_keys_and_ca(None, None).unwrap();
let other_pub = load_other_key().public_key().clone();
assert_eq!(
config.authenticate_publickey(&other_pub),
Err(AuthError::KeyRejected)
);
}
}

View File

@@ -1,58 +0,0 @@
use std::collections::HashMap;
use serde_json::Value;
use crate::call::OperationEnv;
#[derive(Debug, Clone)]
pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<crate::auth::Identity>,
pub metadata: HashMap<String, Value>,
pub env: OperationEnv,
pub trusted: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::call::OperationRegistry;
fn make_context() -> OperationContext {
let registry = OperationRegistry::new();
OperationContext {
request_id: "req-1".to_string(),
parent_request_id: None,
identity: None,
metadata: HashMap::new(),
env: OperationEnv::local(registry),
trusted: false,
}
}
#[test]
fn operation_context_fields() {
let ctx = make_context();
assert_eq!(ctx.request_id, "req-1");
assert!(ctx.parent_request_id.is_none());
assert!(ctx.identity.is_none());
assert!(ctx.metadata.is_empty());
assert!(!ctx.trusted);
}
#[test]
fn operation_context_with_parent() {
let registry = OperationRegistry::new();
let ctx = OperationContext {
request_id: "req-2".to_string(),
parent_request_id: Some("req-1".to_string()),
identity: None,
metadata: HashMap::new(),
env: OperationEnv::local(registry),
trusted: true,
};
assert_eq!(ctx.parent_request_id, Some("req-1".to_string()));
assert!(ctx.trusted);
}
}

View File

@@ -1,190 +0,0 @@
use std::sync::Arc;
use serde_json::Value;
use crate::call::context::OperationContext;
use crate::call::registry::OperationRegistry;
use crate::call::response::ResponseEnvelope;
use crate::credentials::{CredentialProvider, CredentialSet, SecretStoreCredentialProvider};
#[derive(Clone)]
pub struct OperationEnv {
registry: Arc<OperationRegistry>,
credential_provider: Arc<dyn CredentialProvider>,
}
impl std::fmt::Debug for OperationEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OperationEnv")
.field("registry", &self.registry)
.finish()
}
}
impl OperationEnv {
pub fn local(registry: OperationRegistry) -> Self {
Self {
registry: Arc::new(registry),
credential_provider: Arc::new(SecretStoreCredentialProvider::new()),
}
}
pub fn with_credential_provider(
registry: OperationRegistry,
credential_provider: Arc<dyn CredentialProvider>,
) -> Self {
Self {
registry: Arc::new(registry),
credential_provider,
}
}
pub fn credentials(&self, service: &str) -> Option<CredentialSet> {
self.credential_provider.get_credentials(service)
}
pub fn invoke(&self, namespace: &str, operation: &str, input: Value) -> ResponseEnvelope {
let name = format!("/{namespace}/{operation}");
let request_id = format!("env{name}");
let context = OperationContext {
request_id: request_id.clone(),
parent_request_id: None,
identity: None,
metadata: std::collections::HashMap::new(),
env: self.clone(),
trusted: true,
};
self.registry.invoke(&name, input, context)
}
pub fn registry_ref(&self) -> &OperationRegistry {
&self.registry
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::call::registry::OperationRegistryBuilder;
use crate::call::spec::{AccessControl, OperationSpec, OperationType};
use crate::config::{AuthPolicy, DynamicConfig};
use crate::credentials::ConfigCredentialProvider;
use arc_swap::ArcSwap;
use std::collections::HashMap;
fn make_spec(name: &str, namespace: &str) -> OperationSpec {
OperationSpec {
name: name.to_string(),
namespace: namespace.to_string(),
op_type: OperationType::Query,
input_schema: serde_json::json!({}),
output_schema: serde_json::json!({}),
access_control: AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: None,
resource_action: None,
},
}
}
#[test]
fn operation_env_local_invoke() {
let registry = OperationRegistryBuilder::new()
.with(
make_spec("/auth/verify", "auth"),
Arc::new(|_input, _ctx| {
ResponseEnvelope::ok("env-/auth/verify", serde_json::json!({"verified": true}))
}),
)
.build();
let env = OperationEnv::local(registry);
let result = env.invoke("auth", "verify", serde_json::json!({"token": "abc"}));
assert!(result.result.is_ok());
}
#[test]
fn operation_env_invoke_missing() {
let registry = OperationRegistry::new();
let env = OperationEnv::local(registry);
let result = env.invoke("auth", "verify", serde_json::json!(null));
assert!(result.result.is_err());
let err = result.result.unwrap_err();
assert_eq!(err.code, "NOT_FOUND");
}
#[test]
fn operation_env_invoke_trusted() {
let registry = OperationRegistryBuilder::new()
.with(
make_spec("/auth/verify", "auth"),
Arc::new(|_input, ctx| {
assert!(ctx.trusted);
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!({"ok": true}))
}),
)
.build();
let env = OperationEnv::local(registry);
let result = env.invoke("auth", "verify", serde_json::json!(null));
assert!(result.result.is_ok());
}
#[test]
fn operation_env_provides_credentials_from_handler_context() {
let mut credentials = HashMap::new();
credentials.insert(
"vast-ai".to_string(),
CredentialSet::Bearer {
token: "test-token".to_string(),
},
);
let config = DynamicConfig::new(AuthPolicy::empty()).with_credentials(credentials);
let dynamic = Arc::new(ArcSwap::new(Arc::new(config)));
let provider = Arc::new(ConfigCredentialProvider::new(dynamic));
let registry = OperationRegistryBuilder::new()
.with(
make_spec("/test/creds", "test"),
Arc::new(|_input, ctx| {
let creds = ctx.env.credentials("vast-ai");
match creds {
Some(CredentialSet::Bearer { token }) => ResponseEnvelope::ok(
&ctx.request_id,
serde_json::json!({"token": token}),
),
_ => ResponseEnvelope::ok(
&ctx.request_id,
serde_json::json!({"found": false}),
),
}
}),
)
.build();
let env = OperationEnv::with_credential_provider(registry, provider);
let result = env.invoke("test", "creds", serde_json::json!(null));
assert!(result.result.is_ok());
let value = result.result.unwrap();
assert_eq!(value["token"], "test-token");
}
#[test]
fn operation_env_credentials_returns_none_for_missing_service() {
let config = DynamicConfig::default();
let dynamic = Arc::new(ArcSwap::new(Arc::new(config)));
let provider = Arc::new(ConfigCredentialProvider::new(dynamic));
let registry = OperationRegistry::new();
let env = OperationEnv::with_credential_provider(registry, provider);
assert!(env.credentials("nonexistent").is_none());
}
#[test]
fn operation_env_default_credentials_returns_none() {
let registry = OperationRegistry::new();
let env = OperationEnv::local(registry);
assert!(env.credentials("vast-ai").is_none());
}
}

View File

@@ -1,141 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventEnvelope {
#[serde(rename = "type")]
pub r#type: String,
pub id: String,
pub payload: Value,
}
impl EventEnvelope {
pub fn new(event_type: impl Into<String>, id: impl Into<String>, payload: Value) -> Self {
Self {
r#type: event_type.into(),
id: id.into(),
payload,
}
}
pub fn call_requested(id: impl Into<String>, payload: Value) -> Self {
Self::new(super::events::CALL_REQUESTED, id, payload)
}
pub fn call_responded(id: impl Into<String>, payload: Value) -> Self {
Self::new(super::events::CALL_RESPONDED, id, payload)
}
pub fn call_completed(id: impl Into<String>, payload: Value) -> Self {
Self::new(super::events::CALL_COMPLETED, id, payload)
}
pub fn call_aborted(id: impl Into<String>, payload: Value) -> Self {
Self::new(super::events::CALL_ABORTED, id, payload)
}
pub fn call_error(
id: impl Into<String>,
code: impl Into<String>,
message: impl Into<String>,
retryable: bool,
) -> Self {
Self::new(
super::events::CALL_ERROR,
id,
serde_json::json!({
"code": code.into(),
"message": message.into(),
"retryable": retryable,
}),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_envelope_new() {
let env = EventEnvelope::new(
"call.requested",
"req-1",
serde_json::json!({"key": "value"}),
);
assert_eq!(env.r#type, "call.requested");
assert_eq!(env.id, "req-1");
assert_eq!(env.payload, serde_json::json!({"key": "value"}));
}
#[test]
fn event_envelope_serialization() {
let env = EventEnvelope::new(
"call.requested",
"req-1",
serde_json::json!({"key": "value"}),
);
let serialized = serde_json::to_string(&env).unwrap();
let deserialized: EventEnvelope = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.r#type, "call.requested");
assert_eq!(deserialized.id, "req-1");
assert_eq!(deserialized.payload, serde_json::json!({"key": "value"}));
}
#[test]
fn event_envelope_serialization_type_field() {
let env = EventEnvelope::new("call.requested", "req-1", serde_json::json!(null));
let serialized = serde_json::to_string(&env).unwrap();
assert!(serialized.contains("\"type\""));
}
#[test]
fn event_envelope_deserialization() {
let json = r#"{"type":"call.responded","id":"req-42","payload":{"result":"ok"}}"#;
let env: EventEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(env.r#type, "call.responded");
assert_eq!(env.id, "req-42");
assert_eq!(env.payload["result"], "ok");
}
#[test]
fn event_envelope_call_requested() {
let env = EventEnvelope::call_requested("req-1", serde_json::json!({"op": "test"}));
assert_eq!(env.r#type, "call.requested");
assert_eq!(env.id, "req-1");
}
#[test]
fn event_envelope_call_responded() {
let env = EventEnvelope::call_responded("req-1", serde_json::json!({"data": 42}));
assert_eq!(env.r#type, "call.responded");
}
#[test]
fn event_envelope_call_completed() {
let env = EventEnvelope::call_completed("req-1", serde_json::json!(null));
assert_eq!(env.r#type, "call.completed");
}
#[test]
fn event_envelope_call_aborted() {
let env = EventEnvelope::call_aborted("req-1", serde_json::json!({"reason": "cancelled"}));
assert_eq!(env.r#type, "call.aborted");
}
#[test]
fn event_envelope_call_error() {
let env = EventEnvelope::call_error("req-1", "TIMEOUT", "timed out", true);
assert_eq!(env.r#type, "call.error");
assert_eq!(env.id, "req-1");
assert_eq!(env.payload["code"], "TIMEOUT");
assert_eq!(env.payload["message"], "timed out");
assert_eq!(env.payload["retryable"], true);
}
#[test]
fn event_envelope_empty_id() {
let env = EventEnvelope::new("event.broadcast", "", serde_json::json!({"msg": "hello"}));
assert_eq!(env.id, "");
}
}

View File

@@ -1,28 +0,0 @@
pub const CALL_REQUESTED: &str = "call.requested";
pub const CALL_RESPONDED: &str = "call.responded";
pub const CALL_COMPLETED: &str = "call.completed";
pub const CALL_ABORTED: &str = "call.aborted";
pub const CALL_ERROR: &str = "call.error";
pub const SERVICE_LIST: &str = "/services/list";
pub const SERVICE_SCHEMA: &str = "/services/schema";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_type_constants() {
assert_eq!(CALL_REQUESTED, "call.requested");
assert_eq!(CALL_RESPONDED, "call.responded");
assert_eq!(CALL_COMPLETED, "call.completed");
assert_eq!(CALL_ABORTED, "call.aborted");
assert_eq!(CALL_ERROR, "call.error");
}
#[test]
fn service_operation_constants() {
assert_eq!(SERVICE_LIST, "/services/list");
assert_eq!(SERVICE_SCHEMA, "/services/schema");
}
}

View File

@@ -1,239 +0,0 @@
use std::io;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::call::envelope::EventEnvelope;
pub fn encode(envelope: &EventEnvelope) -> Vec<u8> {
let json = serde_json::to_vec(envelope).expect("EventEnvelope serialization must not fail");
let len = json.len() as u32;
let mut frame = Vec::with_capacity(4 + json.len());
frame.extend_from_slice(&len.to_be_bytes());
frame.extend_from_slice(&json);
frame
}
pub fn decode(data: &[u8]) -> Result<EventEnvelope, FrameDecodeError> {
if data.len() < 4 {
return Err(FrameDecodeError::TooShort {
expected: 4,
actual: data.len(),
});
}
let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if data.len() < 4 + len {
return Err(FrameDecodeError::Incomplete {
expected: 4 + len,
actual: data.len(),
});
}
let body = &data[4..4 + len];
let envelope: EventEnvelope = serde_json::from_slice(body).map_err(FrameDecodeError::Json)?;
Ok(envelope)
}
pub fn decode_with_remainder(data: &[u8]) -> Result<(EventEnvelope, usize), FrameDecodeError> {
if data.len() < 4 {
return Err(FrameDecodeError::TooShort {
expected: 4,
actual: data.len(),
});
}
let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
let total = 4 + len;
if data.len() < total {
return Err(FrameDecodeError::Incomplete {
expected: total,
actual: data.len(),
});
}
let body = &data[4..total];
let envelope: EventEnvelope = serde_json::from_slice(body).map_err(FrameDecodeError::Json)?;
Ok((envelope, total))
}
#[derive(Debug, thiserror::Error)]
pub enum FrameDecodeError {
#[error("frame too short: expected at least {expected} bytes, got {actual}")]
TooShort { expected: usize, actual: usize },
#[error("incomplete frame: expected {expected} bytes, got {actual}")]
Incomplete { expected: usize, actual: usize },
#[error("JSON deserialization error: {0}")]
Json(#[from] serde_json::Error),
}
pub struct FrameFramedReader<S> {
stream: S,
buf: Vec<u8>,
}
impl<S> FrameFramedReader<S>
where
S: AsyncRead + Unpin,
{
pub fn new(stream: S) -> Self {
Self {
stream,
buf: Vec::with_capacity(4096),
}
}
pub async fn read_frame(&mut self) -> io::Result<Option<EventEnvelope>> {
loop {
if self.buf.len() >= 4 {
let len = u32::from_be_bytes([self.buf[0], self.buf[1], self.buf[2], self.buf[3]])
as usize;
let total = 4 + len;
if self.buf.len() >= total {
let body = &self.buf[4..total];
match serde_json::from_slice(body) {
Ok(envelope) => {
self.buf.drain(..total);
return Ok(Some(envelope));
}
Err(e) => {
self.buf.drain(..total);
return Err(io::Error::new(io::ErrorKind::InvalidData, e));
}
}
}
}
let mut tmp = [0u8; 4096];
match self.stream.read(&mut tmp).await {
Ok(0) => return Ok(None),
Ok(n) => self.buf.extend_from_slice(&tmp[..n]),
Err(e) => return Err(e),
}
}
}
}
pub struct FrameFramedWriter<S> {
stream: S,
}
impl<S> FrameFramedWriter<S>
where
S: AsyncWrite + Unpin,
{
pub fn new(stream: S) -> Self {
Self { stream }
}
pub async fn write_frame(&mut self, envelope: &EventEnvelope) -> io::Result<()> {
let frame = encode(envelope);
self.stream.write_all(&frame).await?;
self.stream.flush().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::call::events;
use serde_json::json;
#[test]
fn frame_encode_decode_round_trip() {
let envelope = EventEnvelope::new(
events::CALL_REQUESTED,
"req-1",
json!({"namespace": "auth", "operation": "verify"}),
);
let frame = encode(&envelope);
let decoded = decode(&frame).unwrap();
assert_eq!(decoded, envelope);
}
#[test]
fn frame_encode_starts_with_length_prefix() {
let envelope = EventEnvelope::new(events::CALL_REQUESTED, "req-1", json!({}));
let frame = encode(&envelope);
let json = serde_json::to_vec(&envelope).unwrap();
let expected_len = json.len() as u32;
let stored_len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]);
assert_eq!(stored_len, expected_len);
assert_eq!(frame.len(), 4 + json.len());
}
#[test]
fn frame_decode_too_short() {
let data = [0u8; 2];
let result = decode(&data);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err,
FrameDecodeError::TooShort {
expected: 4,
actual: 2
}
));
}
#[test]
fn frame_decode_incomplete() {
let len = 100u32;
let mut data = Vec::new();
data.extend_from_slice(&len.to_be_bytes());
data.extend_from_slice(&[0u8; 10]);
let result = decode(&data);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(
err,
FrameDecodeError::Incomplete {
expected: 104,
actual: 14
}
));
}
#[test]
fn frame_decode_invalid_json() {
let json = b"not valid json";
let mut data = Vec::new();
data.extend_from_slice(&(json.len() as u32).to_be_bytes());
data.extend_from_slice(json);
let result = decode(&data);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), FrameDecodeError::Json(_)));
}
#[test]
fn frame_decode_with_remainder() {
let envelope = EventEnvelope::new(events::CALL_RESPONDED, "req-1", json!({"result": 42}));
let frame = encode(&envelope);
let mut extended = frame.clone();
extended.extend_from_slice(&[0u8; 50]);
let (decoded, consumed) = decode_with_remainder(&extended).unwrap();
assert_eq!(decoded, envelope);
assert_eq!(consumed, frame.len());
}
#[test]
fn frame_encode_decode_empty_payload() {
let envelope = EventEnvelope::new(events::CALL_COMPLETED, "req-1", json!(null));
let frame = encode(&envelope);
let decoded = decode(&frame).unwrap();
assert_eq!(decoded, envelope);
}
#[test]
fn frame_encode_decode_large_payload() {
let large_data: Vec<i32> = (0..1000).collect();
let envelope = EventEnvelope::new(events::CALL_RESPONDED, "req-big", json!(large_data));
let frame = encode(&envelope);
let decoded = decode(&frame).unwrap();
assert_eq!(decoded, envelope);
}
#[test]
fn frame_decode_with_remainder_too_short() {
let data = [0u8; 1];
let result = decode_with_remainder(&data);
assert!(result.is_err());
}
}

View File

@@ -1,28 +0,0 @@
//! Call protocol layer (Layer 3) of the three-layer model.
//!
//! See [ADR-024](docs/architecture/decisions/024-call-protocol.md) and
//! [ADR-033](docs/architecture/decisions/033-call-protocol-extensions.md).
pub mod context;
pub mod env;
pub mod envelope;
pub mod events;
pub mod frame;
pub mod pending;
pub mod registry;
pub mod response;
pub mod services;
pub mod spec;
pub use context::OperationContext;
pub use env::OperationEnv;
pub use envelope::EventEnvelope;
pub use events::{CALL_ABORTED, CALL_COMPLETED, CALL_ERROR, CALL_REQUESTED, CALL_RESPONDED};
pub use frame::{
decode, decode_with_remainder, encode, FrameDecodeError, FrameFramedReader, FrameFramedWriter,
};
pub use pending::PendingRequestMap;
pub use registry::{Handler, OperationRegistry, OperationRegistryBuilder};
pub use response::{CallError, ResponseEnvelope};
pub use services::{register_default_operations, services_list_spec, services_schema_spec};
pub use spec::{AccessControl, OperationSpec, OperationType};

View File

@@ -1,265 +0,0 @@
use std::collections::HashMap;
use std::time::Instant;
use serde_json::Value;
use tokio::sync::{mpsc, oneshot};
use crate::call::response::CallError;
enum PendingEntry {
Call {
tx: oneshot::Sender<Result<Value, CallError>>,
timeout: Instant,
},
Subscribe {
tx: mpsc::Sender<Result<Value, CallError>>,
timeout: Option<Instant>,
},
}
pub struct PendingRequestMap {
pending: HashMap<String, PendingEntry>,
}
impl PendingRequestMap {
pub fn new() -> Self {
Self {
pending: HashMap::new(),
}
}
pub fn insert_call(
&mut self,
request_id: impl Into<String>,
tx: oneshot::Sender<Result<Value, CallError>>,
timeout: Instant,
) {
self.pending
.insert(request_id.into(), PendingEntry::Call { tx, timeout });
}
pub fn insert_subscribe(
&mut self,
request_id: impl Into<String>,
tx: mpsc::Sender<Result<Value, CallError>>,
timeout: Option<Instant>,
) {
self.pending
.insert(request_id.into(), PendingEntry::Subscribe { tx, timeout });
}
pub fn resolve_call(&mut self, request_id: &str, value: Result<Value, CallError>) -> bool {
if let Some(PendingEntry::Call { tx, .. }) = self.pending.remove(request_id) {
let _ = tx.send(value);
true
} else {
false
}
}
pub fn push_subscribe(&mut self, request_id: &str, value: Result<Value, CallError>) -> bool {
match self.pending.get_mut(request_id) {
Some(PendingEntry::Subscribe { tx, .. }) => tx.try_send(value).is_ok(),
_ => false,
}
}
pub fn complete_subscribe(&mut self, request_id: &str) -> bool {
self.pending.remove(request_id).is_some()
}
pub fn abort(&mut self, request_id: &str) -> bool {
self.pending.remove(request_id).is_some()
}
pub fn contains(&self, request_id: &str) -> bool {
self.pending.contains_key(request_id)
}
pub fn len(&self) -> usize {
self.pending.len()
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
pub fn sweep_expired(&mut self, now: Instant) -> usize {
let expired: Vec<String> = self
.pending
.iter()
.filter(|(_, entry)| match entry {
PendingEntry::Call { timeout, .. } => *timeout <= now,
PendingEntry::Subscribe { timeout, .. } => timeout.is_some_and(|t| t <= now),
})
.map(|(id, _)| id.clone())
.collect();
let count = expired.len();
for id in &expired {
self.pending.remove(id);
}
count
}
}
impl Default for PendingRequestMap {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn pending_request_map_insert_and_resolve_call() {
let mut map = PendingRequestMap::new();
let (tx, rx) = oneshot::channel();
let timeout = Instant::now() + Duration::from_secs(30);
map.insert_call("req-1", tx, timeout);
assert!(map.contains("req-1"));
assert_eq!(map.len(), 1);
let result = map.resolve_call("req-1", Ok(serde_json::json!({"status": "ok"})));
assert!(result);
assert!(map.is_empty());
let response = rx.await.unwrap();
assert!(response.is_ok());
assert_eq!(response.unwrap(), serde_json::json!({"status": "ok"}));
}
#[tokio::test]
async fn pending_request_map_resolve_unknown_call() {
let mut map = PendingRequestMap::new();
let result = map.resolve_call("unknown", Ok(serde_json::json!(null)));
assert!(!result);
}
#[tokio::test]
async fn pending_request_map_insert_and_push_subscribe() {
let mut map = PendingRequestMap::new();
let (tx, mut rx) = mpsc::channel(16);
map.insert_subscribe("sub-1", tx, None);
assert!(map.contains("sub-1"));
let pushed = map.push_subscribe("sub-1", Ok(serde_json::json!({"item": 1})));
assert!(pushed);
let response = rx.recv().await.unwrap();
assert!(response.is_ok());
assert_eq!(response.unwrap(), serde_json::json!({"item": 1}));
}
#[tokio::test]
async fn pending_request_map_complete_subscribe() {
let mut map = PendingRequestMap::new();
let (tx, mut rx) = mpsc::channel(16);
map.insert_subscribe("sub-1", tx, None);
map.push_subscribe("sub-1", Ok(serde_json::json!({"item": 1})));
let completed = map.complete_subscribe("sub-1");
assert!(completed);
assert!(map.is_empty());
let _ = rx.recv().await;
}
#[tokio::test]
async fn pending_request_map_abort_call() {
let mut map = PendingRequestMap::new();
let (tx, _rx) = oneshot::channel();
let timeout = Instant::now() + Duration::from_secs(30);
map.insert_call("req-1", tx, timeout);
let aborted = map.abort("req-1");
assert!(aborted);
assert!(map.is_empty());
}
#[tokio::test]
async fn pending_request_map_abort_unknown() {
let mut map = PendingRequestMap::new();
let aborted = map.abort("unknown");
assert!(!aborted);
}
#[tokio::test]
async fn pending_request_map_sweep_expired() {
let mut map = PendingRequestMap::new();
let (tx1, _rx1) = oneshot::channel();
let (tx2, _rx2) = oneshot::channel();
let past = Instant::now() - Duration::from_secs(1);
let future = Instant::now() + Duration::from_secs(30);
map.insert_call("expired-1", tx1, past);
map.insert_call("active-1", tx2, future);
let swept = map.sweep_expired(Instant::now());
assert_eq!(swept, 1);
assert!(!map.contains("expired-1"));
assert!(map.contains("active-1"));
}
#[tokio::test]
async fn pending_request_map_sweep_subscribe_with_timeout() {
let mut map = PendingRequestMap::new();
let (tx1, _rx1) = mpsc::channel(16);
let (tx2, _rx2) = mpsc::channel(16);
let past = Some(Instant::now() - Duration::from_secs(1));
let future = Some(Instant::now() + Duration::from_secs(30));
map.insert_subscribe("expired-sub", tx1, past);
map.insert_subscribe("active-sub", tx2, future);
let swept = map.sweep_expired(Instant::now());
assert_eq!(swept, 1);
assert!(!map.contains("expired-sub"));
assert!(map.contains("active-sub"));
}
#[tokio::test]
async fn pending_request_map_subscribe_no_timeout_not_swept() {
let mut map = PendingRequestMap::new();
let (tx, _rx) = mpsc::channel(16);
map.insert_subscribe("sub-no-timeout", tx, None);
let swept = map.sweep_expired(Instant::now());
assert_eq!(swept, 0);
assert!(map.contains("sub-no-timeout"));
}
#[tokio::test]
async fn pending_request_map_push_unknown_subscribe() {
let mut map = PendingRequestMap::new();
let pushed = map.push_subscribe("unknown", Ok(serde_json::json!(null)));
assert!(!pushed);
}
#[tokio::test]
async fn pending_request_map_call_error_response() {
let mut map = PendingRequestMap::new();
let (tx, rx) = oneshot::channel();
let timeout = Instant::now() + Duration::from_secs(30);
map.insert_call("req-err", tx, timeout);
let result = map.resolve_call(
"req-err",
Err(CallError {
code: "TIMEOUT".to_string(),
message: "request timed out".to_string(),
retryable: true,
}),
);
assert!(result);
assert!(map.is_empty());
let response = rx.await.unwrap();
assert!(response.is_err());
let err = response.unwrap_err();
assert_eq!(err.code, "TIMEOUT");
assert!(err.retryable);
}
}

View File

@@ -1,337 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::Value;
use crate::call::context::OperationContext;
use crate::call::response::ResponseEnvelope;
use crate::call::spec::OperationSpec;
pub type Handler = Arc<dyn Fn(Value, OperationContext) -> ResponseEnvelope + Send + Sync>;
pub struct OperationRegistry {
operations: HashMap<String, (OperationSpec, Handler)>,
}
impl std::fmt::Debug for OperationRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OperationRegistry")
.field("operation_count", &self.operations.len())
.finish()
}
}
impl OperationRegistry {
pub fn new() -> Self {
Self {
operations: HashMap::new(),
}
}
pub fn register(&mut self, spec: OperationSpec, handler: Handler) {
self.operations.insert(spec.name.clone(), (spec, handler));
}
pub fn lookup(&self, name: &str) -> Option<(&OperationSpec, &Handler)> {
self.operations
.get(name)
.map(|(spec, handler)| (spec, handler))
}
pub fn invoke(&self, name: &str, input: Value, context: OperationContext) -> ResponseEnvelope {
match self.lookup(name) {
Some((spec, handler)) => {
if !context.trusted {
if let Some(ref identity) = context.identity {
if !spec.access_control.check(identity) {
return ResponseEnvelope::err(
&context.request_id,
"FORBIDDEN",
"access denied",
false,
);
}
} else if spec.access_control.has_restrictions() {
return ResponseEnvelope::err(
&context.request_id,
"FORBIDDEN",
"authentication required",
false,
);
}
}
handler(input, context)
}
None => ResponseEnvelope::err(
&context.request_id,
"NOT_FOUND",
format!("operation not found: {name}"),
false,
),
}
}
pub fn list_operations(&self) -> Vec<&OperationSpec> {
self.operations.values().map(|(spec, _)| spec).collect()
}
}
impl Default for OperationRegistry {
fn default() -> Self {
Self::new()
}
}
pub struct OperationRegistryBuilder {
registry: OperationRegistry,
}
impl OperationRegistryBuilder {
pub fn new() -> Self {
Self {
registry: OperationRegistry::new(),
}
}
pub fn with(mut self, spec: OperationSpec, handler: Handler) -> Self {
self.registry.register(spec, handler);
self
}
pub fn build(self) -> OperationRegistry {
self.registry
}
}
impl Default for OperationRegistryBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::Identity;
use crate::call::env::OperationEnv;
use crate::call::spec::{AccessControl, OperationType};
use std::collections::HashMap;
fn make_spec(name: &str, namespace: &str) -> OperationSpec {
OperationSpec {
name: name.to_string(),
namespace: namespace.to_string(),
op_type: OperationType::Query,
input_schema: serde_json::json!({}),
output_schema: serde_json::json!({}),
access_control: AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: None,
resource_action: None,
},
}
}
fn make_spec_with_acl(name: &str, namespace: &str, acl: AccessControl) -> OperationSpec {
OperationSpec {
name: name.to_string(),
namespace: namespace.to_string(),
op_type: OperationType::Mutation,
input_schema: serde_json::json!({}),
output_schema: serde_json::json!({}),
access_control: acl,
}
}
fn make_context(request_id: &str, identity: Option<Identity>) -> OperationContext {
let registry = OperationRegistry::new();
OperationContext {
request_id: request_id.to_string(),
parent_request_id: None,
identity,
metadata: HashMap::new(),
env: OperationEnv::local(registry),
trusted: false,
}
}
#[test]
fn register_and_lookup() {
let mut registry = OperationRegistry::new();
let spec = make_spec("fs/readFile", "fs");
let handler: Handler = Arc::new(|input, _ctx| ResponseEnvelope::ok("req-1", input));
registry.register(spec, handler);
let found = registry.lookup("fs/readFile");
assert!(found.is_some());
let (spec, _) = found.unwrap();
assert_eq!(spec.name, "fs/readFile");
assert_eq!(spec.namespace, "fs");
}
#[test]
fn lookup_missing_returns_none() {
let registry = OperationRegistry::new();
assert!(registry.lookup("missing").is_none());
}
#[test]
fn invoke_operation() {
let mut registry = OperationRegistry::new();
let spec = make_spec("fs/readFile", "fs");
let handler: Handler = Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input));
registry.register(spec, handler);
let context = make_context("req-1", None);
let result = registry.invoke("fs/readFile", serde_json::json!({"path": "/tmp"}), context);
assert!(result.result.is_ok());
assert_eq!(result.result.unwrap(), serde_json::json!({"path": "/tmp"}));
}
#[test]
fn invoke_missing_operation() {
let registry = OperationRegistry::new();
let context = make_context("req-1", None);
let result = registry.invoke("missing", serde_json::json!(null), context);
assert!(result.result.is_err());
let err = result.result.unwrap_err();
assert_eq!(err.code, "NOT_FOUND");
}
#[test]
fn invoke_with_acl_check_allowed() {
let mut registry = OperationRegistry::new();
let acl = AccessControl {
required_scopes: vec!["read".to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
};
let spec = make_spec_with_acl("bash/exec", "bash", acl);
let handler: Handler = Arc::new(|_input, ctx| {
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
});
registry.register(spec, handler);
let identity = Identity {
id: "user-1".to_string(),
scopes: vec!["read".to_string()],
resources: HashMap::new(),
};
let context = make_context("req-1", Some(identity));
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
assert!(result.result.is_ok());
}
#[test]
fn invoke_with_acl_check_denied() {
let mut registry = OperationRegistry::new();
let acl = AccessControl {
required_scopes: vec!["admin".to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
};
let spec = make_spec_with_acl("bash/exec", "bash", acl);
let handler: Handler = Arc::new(|_input, ctx| {
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
});
registry.register(spec, handler);
let identity = Identity {
id: "user-1".to_string(),
scopes: vec!["read".to_string()],
resources: HashMap::new(),
};
let context = make_context("req-1", Some(identity));
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
assert!(result.result.is_err());
let err = result.result.unwrap_err();
assert_eq!(err.code, "FORBIDDEN");
}
#[test]
fn invoke_trusted_skips_acl() {
let mut registry = OperationRegistry::new();
let acl = AccessControl {
required_scopes: vec!["admin".to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
};
let spec = make_spec_with_acl("bash/exec", "bash", acl);
let handler: Handler = Arc::new(|_input, ctx| {
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
});
registry.register(spec, handler);
let identity = Identity {
id: "user-1".to_string(),
scopes: vec!["read".to_string()],
resources: HashMap::new(),
};
let mut registry2 = OperationRegistry::new();
let context = OperationContext {
request_id: "req-1".to_string(),
parent_request_id: None,
identity: Some(identity),
metadata: HashMap::new(),
env: OperationEnv::local(registry2),
trusted: true,
};
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
assert!(result.result.is_ok());
}
#[test]
fn invoke_no_identity_with_acl_denied() {
let mut registry = OperationRegistry::new();
let acl = AccessControl {
required_scopes: vec!["read".to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
};
let spec = make_spec_with_acl("bash/exec", "bash", acl);
let handler: Handler = Arc::new(|_input, ctx| {
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!("done"))
});
registry.register(spec, handler);
let context = make_context("req-1", None);
let result = registry.invoke("bash/exec", serde_json::json!(null), context);
assert!(result.result.is_err());
let err = result.result.unwrap_err();
assert_eq!(err.code, "FORBIDDEN");
}
#[test]
fn list_operations() {
let mut registry = OperationRegistry::new();
registry.register(
make_spec("fs/readFile", "fs"),
Arc::new(|_, ctx| ResponseEnvelope::ok(&ctx.request_id, serde_json::json!(null))),
);
registry.register(
make_spec("bash/exec", "bash"),
Arc::new(|_, ctx| ResponseEnvelope::ok(&ctx.request_id, serde_json::json!(null))),
);
let ops = registry.list_operations();
assert_eq!(ops.len(), 2);
}
#[test]
fn registry_builder() {
let registry = OperationRegistryBuilder::new()
.with(
make_spec("fs/readFile", "fs"),
Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input)),
)
.with(
make_spec("bash/exec", "bash"),
Arc::new(|input, ctx| ResponseEnvelope::ok(&ctx.request_id, input)),
)
.build();
assert!(registry.lookup("fs/readFile").is_some());
assert!(registry.lookup("bash/exec").is_some());
}
}

View File

@@ -1,108 +0,0 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CallError {
pub code: String,
pub message: String,
pub retryable: bool,
}
impl CallError {
pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
Self {
code: code.into(),
message: message.into(),
retryable,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseEnvelope {
pub request_id: String,
pub result: Result<Value, CallError>,
}
impl ResponseEnvelope {
pub fn ok(request_id: impl Into<String>, value: Value) -> Self {
Self {
request_id: request_id.into(),
result: Ok(value),
}
}
pub fn err(
request_id: impl Into<String>,
code: impl Into<String>,
message: impl Into<String>,
retryable: bool,
) -> Self {
Self {
request_id: request_id.into(),
result: Err(CallError {
code: code.into(),
message: message.into(),
retryable,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn call_error_fields() {
let err = CallError {
code: "NOT_FOUND".to_string(),
message: "operation not found".to_string(),
retryable: false,
};
assert_eq!(err.code, "NOT_FOUND");
assert_eq!(err.message, "operation not found");
assert!(!err.retryable);
}
#[test]
fn response_envelope_ok() {
let env = ResponseEnvelope::ok("req-1", json!({"status": "ok"}));
assert_eq!(env.request_id, "req-1");
assert!(env.result.is_ok());
assert_eq!(env.result.unwrap(), json!({"status": "ok"}));
}
#[test]
fn response_envelope_err() {
let env = ResponseEnvelope::err("req-1", "NOT_FOUND", "operation not found", false);
assert_eq!(env.request_id, "req-1");
assert!(env.result.is_err());
let err = env.result.unwrap_err();
assert_eq!(err.code, "NOT_FOUND");
assert_eq!(err.message, "operation not found");
assert!(!err.retryable);
}
#[test]
fn response_envelope_serialization() {
let env = ResponseEnvelope::ok("req-1", json!({"key": "value"}));
let serialized = serde_json::to_string(&env).unwrap();
let deserialized: ResponseEnvelope = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.request_id, "req-1");
assert!(deserialized.result.is_ok());
}
#[test]
fn response_envelope_err_serialization() {
let env = ResponseEnvelope::err("req-2", "TIMEOUT", "timed out", true);
let serialized = serde_json::to_string(&env).unwrap();
let deserialized: ResponseEnvelope = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.request_id, "req-2");
let err = deserialized.result.unwrap_err();
assert_eq!(err.code, "TIMEOUT");
assert!(err.retryable);
}
}

View File

@@ -1,207 +0,0 @@
use std::sync::Arc;
use serde_json::Value;
use crate::call::context::OperationContext;
use crate::call::response::ResponseEnvelope;
use crate::call::spec::{AccessControl, OperationSpec, OperationType};
pub fn services_list_spec() -> OperationSpec {
OperationSpec {
name: super::events::SERVICE_LIST.to_string(),
namespace: "services".to_string(),
op_type: OperationType::Query,
input_schema: serde_json::json!({
"type": "object",
"properties": {},
}),
output_schema: serde_json::json!({
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"namespace": { "type": "string" },
"op_type": { "type": "string" },
},
},
}),
access_control: AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: None,
resource_action: None,
},
}
}
pub fn services_schema_spec() -> OperationSpec {
OperationSpec {
name: super::events::SERVICE_SCHEMA.to_string(),
namespace: "services".to_string(),
op_type: OperationType::Query,
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
},
"required": ["name"],
}),
output_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"namespace": { "type": "string" },
"op_type": { "type": "string" },
"input_schema": { "type": "object" },
"output_schema": { "type": "object" },
},
}),
access_control: AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: None,
resource_action: None,
},
}
}
pub fn register_default_operations(registry: &mut crate::call::OperationRegistry) {
registry.register(services_list_spec(), Arc::new(services_list_handler));
registry.register(services_schema_spec(), Arc::new(services_schema_handler));
}
fn services_list_handler(_input: Value, ctx: OperationContext) -> ResponseEnvelope {
let registry = &ctx.env.registry_ref();
let specs = registry.list_operations();
let ops: Vec<Value> = specs
.iter()
.map(|spec| {
serde_json::json!({
"name": spec.name,
"namespace": spec.namespace,
"op_type": format!("{:?}", spec.op_type).to_lowercase(),
})
})
.collect();
ResponseEnvelope::ok(&ctx.request_id, serde_json::json!({ "operations": ops }))
}
fn services_schema_handler(input: Value, ctx: OperationContext) -> ResponseEnvelope {
let name = match input.get("name").and_then(|v| v.as_str()) {
Some(n) => n.to_string(),
None => {
return ResponseEnvelope::err(
&ctx.request_id,
"INVALID_INPUT",
"missing required field: name",
false,
);
}
};
let registry = &ctx.env.registry_ref();
match registry.lookup(&name) {
Some((spec, _)) => ResponseEnvelope::ok(
&ctx.request_id,
serde_json::json!({
"name": spec.name,
"namespace": spec.namespace,
"op_type": format!("{:?}", spec.op_type).to_lowercase(),
"input_schema": spec.input_schema,
"output_schema": spec.output_schema,
}),
),
None => ResponseEnvelope::err(
&ctx.request_id,
"NOT_FOUND",
format!("operation not found: {name}"),
false,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::call::env::OperationEnv;
fn make_env() -> OperationEnv {
let mut registry = crate::call::OperationRegistry::new();
registry.register(services_list_spec(), Arc::new(services_list_handler));
registry.register(services_schema_spec(), Arc::new(services_schema_handler));
OperationEnv::local(registry)
}
#[test]
fn services_list_returns_operations() {
let env = make_env();
let result = env.invoke("services", "list", serde_json::json!({}));
assert!(result.result.is_ok());
let value = result.result.unwrap();
let ops = value.get("operations").unwrap().as_array().unwrap();
assert_eq!(ops.len(), 2);
}
#[test]
fn services_schema_returns_spec() {
let env = make_env();
let result = env.invoke(
"services",
"schema",
serde_json::json!({"name": "/services/list"}),
);
assert!(result.result.is_ok());
let value = result.result.unwrap();
assert_eq!(value["name"], "/services/list");
assert_eq!(value["namespace"], "services");
}
#[test]
fn services_schema_missing_name() {
let env = make_env();
let result = env.invoke("services", "schema", serde_json::json!({}));
assert!(result.result.is_err());
let err = result.result.unwrap_err();
assert_eq!(err.code, "INVALID_INPUT");
}
#[test]
fn services_schema_not_found() {
let env = make_env();
let result = env.invoke(
"services",
"schema",
serde_json::json!({"name": "/nonexistent/op"}),
);
assert!(result.result.is_err());
let err = result.result.unwrap_err();
assert_eq!(err.code, "NOT_FOUND");
}
#[test]
fn services_list_spec_fields() {
let spec = services_list_spec();
assert_eq!(spec.name, "/services/list");
assert_eq!(spec.namespace, "services");
assert_eq!(spec.op_type, OperationType::Query);
assert!(!spec.access_control.has_restrictions());
}
#[test]
fn services_schema_spec_fields() {
let spec = services_schema_spec();
assert_eq!(spec.name, "/services/schema");
assert_eq!(spec.namespace, "services");
assert_eq!(spec.op_type, OperationType::Query);
assert!(!spec.access_control.has_restrictions());
}
#[test]
fn register_default_operations_adds_both() {
let mut registry = crate::call::OperationRegistry::new();
register_default_operations(&mut registry);
assert!(registry.lookup("/services/list").is_some());
assert!(registry.lookup("/services/schema").is_some());
assert_eq!(registry.list_operations().len(), 2);
}
}

View File

@@ -1,239 +0,0 @@
//! Operation specifications (type, access control) for the call protocol.
//!
//! See [ADR-025](docs/architecture/decisions/025-operation-spec.md) and
//! [ADR-033](docs/architecture/decisions/033-call-protocol-extensions.md).
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum OperationType {
Query,
Mutation,
Subscription,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessControl {
pub required_scopes: Vec<String>,
pub required_scopes_any: Option<Vec<String>>,
pub resource_type: Option<String>,
pub resource_action: Option<String>,
}
impl AccessControl {
pub fn check(&self, identity: &crate::auth::Identity) -> bool {
for scope in &self.required_scopes {
if !identity.scopes.contains(scope) {
return false;
}
}
if let Some(any) = &self.required_scopes_any {
if !any.iter().any(|s| identity.scopes.contains(s)) {
return false;
}
}
if let Some(res_type) = &self.resource_type {
if let Some(actions) = identity.resources.get(res_type) {
if let Some(action) = &self.resource_action {
if !actions.contains(action) {
return false;
}
}
} else {
return false;
}
}
true
}
pub fn has_restrictions(&self) -> bool {
!self.required_scopes.is_empty()
|| self.required_scopes_any.is_some()
|| self.resource_type.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperationSpec {
pub name: String,
pub namespace: String,
pub op_type: OperationType,
pub input_schema: Value,
pub output_schema: Value,
pub access_control: AccessControl,
}
impl OperationSpec {
pub fn path(&self) -> String {
format!("/{}", self.name)
}
pub fn namespace_from_name(name: &str) -> String {
let trimmed = name.trim_start_matches('/');
let parts: Vec<&str> = trimmed.split('/').collect();
match parts.len() {
n if n >= 3 => parts[1].to_string(),
n if n >= 2 => parts[0].to_string(),
_ => String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_identity(
scopes: Vec<String>,
resources: HashMap<String, Vec<String>>,
) -> crate::auth::Identity {
crate::auth::Identity {
id: "test".to_string(),
scopes,
resources,
}
}
#[test]
fn access_control_allows_matching_scopes() {
let ac = AccessControl {
required_scopes: vec!["read".to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
};
let id = make_identity(vec!["read".to_string()], HashMap::new());
assert!(ac.check(&id));
}
#[test]
fn access_control_rejects_missing_scopes() {
let ac = AccessControl {
required_scopes: vec!["admin".to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
};
let id = make_identity(vec!["read".to_string()], HashMap::new());
assert!(!ac.check(&id));
}
#[test]
fn access_control_required_scopes_any_matches() {
let ac = AccessControl {
required_scopes: vec![],
required_scopes_any: Some(vec!["admin".to_string(), "read".to_string()]),
resource_type: None,
resource_action: None,
};
let id = make_identity(vec!["read".to_string()], HashMap::new());
assert!(ac.check(&id));
}
#[test]
fn access_control_required_scopes_any_rejects() {
let ac = AccessControl {
required_scopes: vec![],
required_scopes_any: Some(vec!["admin".to_string()]),
resource_type: None,
resource_action: None,
};
let id = make_identity(vec!["read".to_string()], HashMap::new());
assert!(!ac.check(&id));
}
#[test]
fn access_control_resource_check_matches() {
let mut resources = HashMap::new();
resources.insert("service".to_string(), vec!["read".to_string()]);
let ac = AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: Some("service".to_string()),
resource_action: Some("read".to_string()),
};
let id = make_identity(vec![], resources);
assert!(ac.check(&id));
}
#[test]
fn access_control_resource_check_missing_resource_type() {
let ac = AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: Some("service".to_string()),
resource_action: Some("read".to_string()),
};
let id = make_identity(vec![], HashMap::new());
assert!(!ac.check(&id));
}
#[test]
fn access_control_resource_check_missing_action() {
let mut resources = HashMap::new();
resources.insert("service".to_string(), vec!["write".to_string()]);
let ac = AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: Some("service".to_string()),
resource_action: Some("read".to_string()),
};
let id = make_identity(vec![], resources);
assert!(!ac.check(&id));
}
#[test]
fn access_control_combined_scopes_and_resources() {
let mut resources = HashMap::new();
resources.insert("service".to_string(), vec!["read".to_string()]);
let ac = AccessControl {
required_scopes: vec!["relay:connect".to_string()],
required_scopes_any: Some(vec!["admin".to_string()]),
resource_type: Some("service".to_string()),
resource_action: Some("read".to_string()),
};
let id = make_identity(
vec!["relay:connect".to_string(), "admin".to_string()],
resources,
);
assert!(ac.check(&id));
}
#[test]
fn operation_type_variants() {
assert_eq!(OperationType::Query, OperationType::Query);
assert_ne!(OperationType::Query, OperationType::Mutation);
assert_ne!(OperationType::Mutation, OperationType::Subscription);
}
#[test]
fn operation_spec_namespace_from_name() {
assert_eq!(OperationSpec::namespace_from_name("/auth/verify"), "auth");
assert_eq!(OperationSpec::namespace_from_name("/fs/readFile"), "fs");
assert_eq!(
OperationSpec::namespace_from_name("/head/agent/chat"),
"agent"
);
}
#[test]
fn operation_spec_path() {
let spec = OperationSpec {
name: "auth/verify".to_string(),
namespace: "auth".to_string(),
op_type: OperationType::Query,
input_schema: serde_json::json!({}),
output_schema: serde_json::json!({}),
access_control: AccessControl {
required_scopes: vec![],
required_scopes_any: None,
resource_type: None,
resource_action: None,
},
};
assert_eq!(spec.path(), "/auth/verify");
}
}

View File

@@ -1,468 +0,0 @@
//! Channel manager with automatic reconnection.
//!
//! Owns the SSH session handle and provides `open_direct_tcpip()`,
//! `request_tcpip_forward()`, and `cancel_tcpip_forward()`. Monitors
//! the session for disconnect and attempts reconnection with exponential
//! backoff (1s, 2s, 4s, ..., 30s cap). Re-registers remote forwards
//! after successful reconnection.
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use russh::client;
use tokio::sync::RwLock;
use tokio::time;
use tracing::{debug, error, info, warn};
use crate::auth::client_auth::{ClientAuthConfig, ClientHandler};
use crate::error::ChannelError;
use crate::transport::Transport;
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct ForwardRequest {
pub addr: String,
pub port: u32,
}
struct ChannelManagerInner<T: Transport> {
transport: Arc<T>,
auth_config: Arc<ClientAuthConfig>,
handle: Arc<RwLock<client::Handle<ClientHandler>>>,
username: String,
forwards: RwLock<HashSet<ForwardRequest>>,
reconnect_attempts: RwLock<u32>,
}
pub struct ChannelManager<T: Transport> {
inner: Arc<ChannelManagerInner<T>>,
reconnect_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
}
impl<T: Transport> ChannelManager<T> {
pub async fn new(
transport: Arc<T>,
auth_config: Arc<ClientAuthConfig>,
username: String,
) -> Result<Self, ChannelError> {
let handler = ClientHandler::from_config(&auth_config);
let handle = Self::establish_session(&*transport, handler, &auth_config, &username)
.await
.map_err(|_| ChannelError::TargetUnreachable)?;
let inner = Arc::new(ChannelManagerInner {
transport,
auth_config,
handle: Arc::new(RwLock::new(handle)),
username,
forwards: RwLock::new(HashSet::new()),
reconnect_attempts: RwLock::new(0),
});
let reconnect_handle = Arc::new(RwLock::new(None));
let manager = Self {
inner,
reconnect_handle,
};
manager.start_reconnect_monitor();
Ok(manager)
}
async fn establish_session(
transport: &T,
handler: ClientHandler,
auth_config: &ClientAuthConfig,
username: &str,
) -> Result<client::Handle<ClientHandler>, russh::Error> {
let stream = transport.connect().await.map_err(|e| {
error!("transport connect failed: {e}");
russh::Error::SendError
})?;
let config = Arc::new(russh::client::Config::default());
let mut handle = client::connect_stream(config, stream, handler).await?;
let auth_ok = auth_config.authenticate(&mut handle, username).await?;
if !auth_ok {
return Err(russh::Error::SendError);
}
Ok(handle)
}
pub async fn open_direct_tcpip(
&self,
host: &str,
port: u32,
) -> Result<russh::Channel<russh::client::Msg>, ChannelError> {
let handle = self.inner.handle.read().await;
handle
.channel_open_direct_tcpip(host, port, "127.0.0.1", 0)
.await
.map_err(|e| {
debug!("channel open failed: {e}");
ChannelError::ChannelClosed
})
}
pub async fn request_tcpip_forward(&self, addr: &str, port: u32) -> Result<u32, ChannelError> {
let mut handle = self.inner.handle.write().await;
let result = handle
.tcpip_forward(addr, port)
.await
.map_err(|_| ChannelError::ChannelClosed)?;
self.inner.forwards.write().await.insert(ForwardRequest {
addr: addr.to_string(),
port,
});
Ok(result)
}
pub async fn cancel_tcpip_forward(&self, addr: &str, port: u32) -> Result<(), ChannelError> {
let handle = self.inner.handle.read().await;
handle
.cancel_tcpip_forward(addr, port)
.await
.map_err(|_| ChannelError::ChannelClosed)?;
self.inner.forwards.write().await.remove(&ForwardRequest {
addr: addr.to_string(),
port,
});
Ok(())
}
pub async fn is_connected(&self) -> bool {
let handle = self.inner.handle.read().await;
!handle.is_closed()
}
fn start_reconnect_monitor(&self) {
let inner = Arc::clone(&self.inner);
let handle_arc = Arc::clone(&self.inner.handle);
let join_handle = tokio::spawn(async move {
loop {
time::sleep(Duration::from_secs(1)).await;
let handle = handle_arc.read().await;
if handle.is_closed() {
drop(handle);
info!("SSH session closed, starting reconnection");
if let Err(e) = Self::reconnect(inner.clone()).await {
error!("reconnection failed: {e}");
}
}
}
});
let reconnect_handle = Arc::clone(&self.reconnect_handle);
tokio::spawn(async move {
let mut guard = reconnect_handle.write().await;
*guard = Some(join_handle);
});
}
async fn reconnect(inner: Arc<ChannelManagerInner<T>>) -> Result<(), ChannelError> {
let mut attempts = inner.reconnect_attempts.write().await;
let attempt_num = *attempts;
let backoff = backoff_duration(attempt_num);
*attempts += 1;
drop(attempts);
warn!(
"reconnect attempt #{}, waiting {:?}",
attempt_num + 1,
backoff
);
time::sleep(backoff).await;
let handler = ClientHandler::from_config(&inner.auth_config);
match Self::establish_session(
&*inner.transport,
handler,
&inner.auth_config,
&inner.username,
)
.await
{
Ok(new_handle) => {
info!("reconnection successful");
{
let mut handle_guard = inner.handle.write().await;
*handle_guard = new_handle;
}
{
let mut attempts = inner.reconnect_attempts.write().await;
*attempts = 0;
}
Self::re_register_forwards(&inner).await;
Ok(())
}
Err(e) => {
warn!("reconnection attempt failed: {e}");
Err(ChannelError::ChannelClosed)
}
}
}
async fn re_register_forwards(inner: &ChannelManagerInner<T>) {
let forwards = inner.forwards.read().await;
if forwards.is_empty() {
return;
}
let mut handle = inner.handle.write().await;
for fwd in forwards.iter() {
match handle.tcpip_forward(&fwd.addr, fwd.port).await {
Ok(_) => {
debug!("re-registered tcpip_forward: {}:{}", fwd.addr, fwd.port);
}
Err(e) => {
warn!(
"failed to re-register tcpip_forward {}:{}: {e}",
fwd.addr, fwd.port
);
}
}
}
}
}
/// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (cap), continues indefinitely.
fn backoff_duration(attempt: u32) -> Duration {
let secs: u64 = match attempt {
0 => 1,
1 => 2,
2 => 4,
3 => 8,
4 => 16,
_ => 30,
};
Duration::from_secs(secs)
}
impl<T: Transport> Drop for ChannelManager<T> {
fn drop(&mut self) {
if let Ok(mut guard) = self.reconnect_handle.try_write() {
if let Some(handle) = guard.take() {
handle.abort();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::duplex;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
fn make_auth_config() -> Arc<ClientAuthConfig> {
let source = crate::auth::keys::KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
Arc::new(ClientAuthConfig::from_key_source(source).unwrap())
}
struct AlwaysFailTransport;
#[async_trait::async_trait]
impl Transport for AlwaysFailTransport {
type Stream = tokio::io::DuplexStream;
async fn connect(&self) -> anyhow::Result<Self::Stream> {
Err(anyhow::anyhow!("always fails"))
}
fn describe(&self) -> String {
"always-fail".to_string()
}
}
struct TrackConnectTransport {
connect_count: Arc<AtomicUsize>,
}
impl TrackConnectTransport {
fn new() -> Self {
Self {
connect_count: Arc::new(AtomicUsize::new(0)),
}
}
}
#[async_trait::async_trait]
impl Transport for TrackConnectTransport {
type Stream = tokio::io::DuplexStream;
async fn connect(&self) -> anyhow::Result<Self::Stream> {
self.connect_count.fetch_add(1, Ordering::SeqCst);
let (client, _) = duplex(4096);
Ok(client)
}
fn describe(&self) -> String {
"track-connect".to_string()
}
}
struct CountingFailTransport {
fail_count: Arc<AtomicUsize>,
succeed_after: usize,
}
impl CountingFailTransport {
fn new(succeed_after: usize) -> Self {
Self {
fail_count: Arc::new(AtomicUsize::new(0)),
succeed_after,
}
}
}
#[async_trait::async_trait]
impl Transport for CountingFailTransport {
type Stream = tokio::io::DuplexStream;
async fn connect(&self) -> anyhow::Result<Self::Stream> {
let count = self.fail_count.fetch_add(1, Ordering::SeqCst);
if count < self.succeed_after {
return Err(anyhow::anyhow!("connection failed (attempt {})", count));
}
let (client, _) = duplex(4096);
Ok(client)
}
fn describe(&self) -> String {
"counting-fail".to_string()
}
}
#[test]
fn test_backoff_durations() {
assert_eq!(backoff_duration(0), Duration::from_secs(1));
assert_eq!(backoff_duration(1), Duration::from_secs(2));
assert_eq!(backoff_duration(2), Duration::from_secs(4));
assert_eq!(backoff_duration(3), Duration::from_secs(8));
assert_eq!(backoff_duration(4), Duration::from_secs(16));
assert_eq!(backoff_duration(5), Duration::from_secs(30));
assert_eq!(backoff_duration(6), Duration::from_secs(30));
assert_eq!(backoff_duration(100), Duration::from_secs(30));
}
#[test]
fn test_backoff_sequence_matches_spec() {
let sequence: Vec<Duration> = (0..6).map(backoff_duration).collect();
assert_eq!(
sequence,
vec![
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(16),
Duration::from_secs(30),
]
);
}
#[test]
fn test_forward_request_hash_eq() {
let fwd1 = ForwardRequest {
addr: "0.0.0.0".to_string(),
port: 8080,
};
let fwd2 = ForwardRequest {
addr: "0.0.0.0".to_string(),
port: 8080,
};
let fwd3 = ForwardRequest {
addr: "0.0.0.0".to_string(),
port: 9090,
};
assert_eq!(fwd1, fwd2);
assert_ne!(fwd1, fwd3);
let mut set = HashSet::new();
set.insert(fwd1.clone());
assert!(set.contains(&fwd2));
assert!(!set.contains(&fwd3));
}
#[tokio::test]
async fn test_channel_manager_new_transport_fails() {
let auth = make_auth_config();
let transport = Arc::new(AlwaysFailTransport);
let result = ChannelManager::new(transport, auth, "testuser".to_string()).await;
assert!(result.is_err());
match result {
Err(ChannelError::TargetUnreachable) => {}
other => panic!("expected TargetUnreachable, got {:?}", other.as_ref().err()),
}
}
#[tokio::test]
async fn test_transport_connect_called_on_new() {
let transport = Arc::new(TrackConnectTransport::new());
let connect_before = transport.connect_count.load(Ordering::SeqCst);
assert_eq!(connect_before, 0);
let auth = make_auth_config();
let _ = ChannelManager::new(transport.clone(), auth, "testuser".to_string()).await;
let connect_after = transport.connect_count.load(Ordering::SeqCst);
assert!(connect_after > 0);
}
#[tokio::test]
async fn test_reconnect_monitor_detects_closed_handle() {
let auth = make_auth_config();
let transport = Arc::new(TrackConnectTransport::new());
let handler = ClientHandler::from_config(&auth);
let config = Arc::new(russh::client::Config::default());
let stream = transport.connect().await.unwrap();
let handle = client::connect_stream(config, stream, handler).await;
match handle {
Ok(h) => {
assert!(!h.is_closed());
drop(h);
}
Err(_) => {
// connect_stream fails without a real SSH server,
// but the concept is verified: dropped handle => is_closed
}
}
}
#[tokio::test]
async fn test_forward_set_tracks_requests() {
let mut set: HashSet<ForwardRequest> = HashSet::new();
set.insert(ForwardRequest {
addr: "0.0.0.0".to_string(),
port: 8080,
});
set.insert(ForwardRequest {
addr: "0.0.0.0".to_string(),
port: 9090,
});
assert_eq!(set.len(), 2);
set.remove(&ForwardRequest {
addr: "0.0.0.0".to_string(),
port: 8080,
});
assert_eq!(set.len(), 1);
assert!(set.contains(&ForwardRequest {
addr: "0.0.0.0".to_string(),
port: 9090,
}));
}
#[test]
fn test_backoff_indefinitely_beyond_cap() {
for attempt in 0..50 {
let duration = backoff_duration(attempt);
assert!(duration <= Duration::from_secs(30));
assert!(duration >= Duration::from_secs(1));
}
}
}

View File

@@ -1,877 +0,0 @@
//! Client session management and connection logic.
//!
//! `ClientSession` establishes an SSH connection over a transport, authenticates,
//! starts a SOCKS5 proxy, sets up port forwards, and monitors for reconnection.
//! `ConnectOptions` provides a builder-pattern API for programmatic configuration.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use russh::client;
use russh::keys::PrivateKey;
use tokio::sync::Mutex;
use tracing::{debug, error, info, warn};
use crate::auth::client_auth::{ClientAuthConfig, ClientHandler};
use crate::auth::keys::KeySource;
use crate::client::forward::{LocalForwarder, PortForwardSpec, RemoteForwarder};
use crate::error::ConfigError;
use crate::socks5::{HandleChannelOpener, Socks5Server};
use crate::transport::Transport;
const DEFAULT_SOCKS5_ADDR: &str = "127.0.0.1:1080";
const DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
/// Transport mode for the client connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportMode {
Tcp,
Tls,
Iroh,
}
impl std::fmt::Display for TransportMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransportMode::Tcp => write!(f, "tcp"),
TransportMode::Tls => write!(f, "tls"),
TransportMode::Iroh => write!(f, "iroh"),
}
}
}
/// Programmatic configuration for an alknet client session.
///
/// Construct with `ConnectOptions::new(key_source)` and chain builder methods.
/// Call `validate()` before passing to `ClientSession::new()`.
///
/// ```
/// use alknet_core::client::{ConnectOptions, TransportMode};
/// use alknet_core::auth::keys::KeySource;
///
/// let opts = ConnectOptions::new(KeySource::File("/path/to/key".into()))
/// .server("example.com:22")
/// .transport_mode(TransportMode::Tcp)
/// .socks5_addr("127.0.0.1:1080")
/// .forward("5432:db.internal:5432");
/// opts.validate().unwrap();
/// ```
#[derive(Clone)]
pub struct ConnectOptions {
pub server: Option<String>,
pub peer: Option<String>,
pub transport_mode: TransportMode,
pub identity: KeySource,
pub socks5_addr: String,
pub forwards: Vec<String>,
pub remote_forwards: Vec<String>,
pub proxy: Option<String>,
pub iroh_relay: Option<String>,
pub tls_server_name: Option<String>,
pub insecure: bool,
}
impl ConnectOptions {
pub fn new(identity: KeySource) -> Self {
Self {
server: None,
peer: None,
transport_mode: TransportMode::Tcp,
identity,
socks5_addr: DEFAULT_SOCKS5_ADDR.to_string(),
forwards: Vec::new(),
remote_forwards: Vec::new(),
proxy: None,
iroh_relay: None,
tls_server_name: None,
insecure: false,
}
}
pub fn server(mut self, addr: impl Into<String>) -> Self {
self.server = Some(addr.into());
self
}
pub fn peer(mut self, endpoint_id: impl Into<String>) -> Self {
self.peer = Some(endpoint_id.into());
self
}
pub fn transport_mode(mut self, mode: TransportMode) -> Self {
self.transport_mode = mode;
self
}
pub fn socks5_addr(mut self, addr: impl Into<String>) -> Self {
self.socks5_addr = addr.into();
self
}
pub fn forward(mut self, spec: impl Into<String>) -> Self {
self.forwards.push(spec.into());
self
}
pub fn remote_forward(mut self, spec: impl Into<String>) -> Self {
self.remote_forwards.push(spec.into());
self
}
pub fn proxy(mut self, url: impl Into<String>) -> Self {
self.proxy = Some(url.into());
self
}
pub fn iroh_relay(mut self, url: impl Into<String>) -> Self {
self.iroh_relay = Some(url.into());
self
}
pub fn tls_server_name(mut self, name: impl Into<String>) -> Self {
self.tls_server_name = Some(name.into());
self
}
pub fn insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
pub fn validate(&self) -> Result<(), ConfigError> {
match self.transport_mode {
TransportMode::Tcp | TransportMode::Tls => {
if self.server.is_none() {
return Err(ConfigError::InvalidFlag {
name: "--server is required for tcp/tls transport".to_string(),
});
}
}
TransportMode::Iroh => {
if self.peer.is_none() {
return Err(ConfigError::InvalidFlag {
name: "--peer is required for iroh transport".to_string(),
});
}
}
}
Ok(())
}
}
impl std::fmt::Debug for ConnectOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConnectOptions")
.field("server", &self.server)
.field("peer", &self.peer)
.field("transport_mode", &self.transport_mode)
.field("identity", &"<KeySource>")
.field("socks5_addr", &self.socks5_addr)
.field("forwards", &self.forwards)
.field("remote_forwards", &self.remote_forwards)
.field("proxy", &self.proxy)
.field("iroh_relay", &self.iroh_relay)
.field("tls_server_name", &self.tls_server_name)
.field("insecure", &self.insecure)
.finish()
}
}
/// An active SSH client session over a transport.
///
/// Establishes the connection, authenticates, and runs a SOCKS5 proxy plus
/// port forwards until shutdown or transport failure. On transport failure,
/// attempts reconnection with exponential backoff (1s, 2s, 4s, ..., 30s cap).
pub struct ClientSession<T: Transport> {
opts: ConnectOptions,
transport: Arc<T>,
handle: Arc<Mutex<client::Handle<ClientHandler>>>,
auth_config: Arc<ClientAuthConfig>,
#[allow(dead_code)]
private_key: Arc<PrivateKey>,
#[allow(dead_code)]
username: String,
shutdown_tx: tokio::sync::watch::Sender<bool>,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
}
impl<T: Transport> ClientSession<T> {
pub async fn new(opts: ConnectOptions, transport: Arc<T>) -> Result<Self, ConnectError> {
opts.validate().map_err(ConnectError::Config)?;
let auth_config = Arc::new(
ClientAuthConfig::from_key_source(opts.identity.clone())
.map_err(ConnectError::Config)?,
);
let private_key = auth_config.private_key();
let username = derive_username();
let handler = ClientHandler::from_config(&auth_config);
let stream = transport.connect().await.map_err(|e| {
error!("transport connect failed: {e}");
ConnectError::ConnectionFailed
})?;
let config = Arc::new(client::Config::default());
let mut handle = client::connect_stream(config, stream, handler)
.await
.map_err(|e| {
error!("SSH connect failed: {e}");
ConnectError::ConnectionFailed
})?;
let auth_ok = auth_config
.authenticate(&mut handle, &username)
.await
.map_err(|_| ConnectError::AuthFailed)?;
if !auth_ok {
return Err(ConnectError::AuthFailed);
}
let handle = Arc::new(Mutex::new(handle));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
Ok(Self {
opts,
transport,
handle,
auth_config,
private_key,
username,
shutdown_tx,
shutdown_rx,
})
}
pub fn handle(&self) -> Arc<Mutex<client::Handle<ClientHandler>>> {
Arc::clone(&self.handle)
}
pub fn auth_config(&self) -> &Arc<ClientAuthConfig> {
&self.auth_config
}
pub fn transport(&self) -> &Arc<T> {
&self.transport
}
pub fn options(&self) -> &ConnectOptions {
&self.opts
}
pub fn shutdown_sender(&self) -> tokio::sync::watch::Sender<bool> {
self.shutdown_tx.clone()
}
pub async fn run(self) -> Result<(), ConnectError> {
let socks5_addr: SocketAddr = self.opts.socks5_addr.parse().map_err(|_| {
ConnectError::Config(ConfigError::InvalidFlag {
name: format!("invalid SOCKS5 address: {}", self.opts.socks5_addr),
})
})?;
let channel_opener = HandleChannelOpener::from_arc(Arc::clone(&self.handle));
let socks5_server = Socks5Server::with_addr(channel_opener, &socks5_addr.to_string());
let socks5_listen = socks5_server.listen_addr();
let local_forwarders = build_local_forwarders(&self.opts)?;
let remote_specs = build_remote_specs(&self.opts)?;
for spec in &remote_specs {
let remote_forwarder =
RemoteForwarder::new(spec.clone()).map_err(|_| ConnectError::ForwardFailed)?;
let mut h = self.handle.lock().await;
remote_forwarder.register(&mut h).await.map_err(|_| {
warn!("failed to register remote forward {}", spec);
ConnectError::ForwardFailed
})?;
info!("registered remote forward: {}", spec);
}
let socks5_task = tokio::spawn(async move {
debug!("SOCKS5 server starting on {}", socks5_listen);
if let Err(e) = socks5_server.run().await {
error!("SOCKS5 server error: {e}");
}
});
let fwd_handle = Arc::clone(&self.handle);
let fwd_shutdown = self.shutdown_rx.clone();
let forward_task = tokio::spawn(async move {
crate::client::forward::run_local_forwarders(
local_forwarders,
fwd_handle,
fwd_shutdown,
)
.await;
});
info!("alknet client running: SOCKS5 on {}", socks5_listen);
#[cfg(unix)]
let signal_done = {
let sig_tx = self.shutdown_tx.clone();
tokio::spawn(async move {
let mut sigterm_stream =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
tokio::select! {
_ = sigterm_stream.recv() => {
info!("received SIGTERM");
}
_ = tokio::signal::ctrl_c() => {
info!("received SIGINT (Ctrl+C)");
}
}
let _ = sig_tx.send(true);
})
};
let mut wait_shutdown = self.shutdown_rx.clone();
let reconnect_handle = Arc::clone(&self.handle);
let reconnect_transport = Arc::clone(&self.transport);
let reconnect_auth = Arc::clone(&self.auth_config);
let reconnect_username = self.username.clone();
let reconnect_shutdown = self.shutdown_rx.clone();
let reconnect_remote_specs = remote_specs.clone();
let reconnect_monitor = tokio::spawn(async move {
let mut attempts: u32 = 0;
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
if *reconnect_shutdown.borrow() {
break;
}
let h = reconnect_handle.lock().await;
if h.is_closed() {
drop(h);
info!("SSH session closed, starting reconnection");
let backoff = backoff_duration(attempts);
warn!("reconnect attempt #{}, waiting {:?}", attempts + 1, backoff);
tokio::time::sleep(backoff).await;
let handler = ClientHandler::from_config(&reconnect_auth);
let username = reconnect_username.clone();
match establish_session(
&*reconnect_transport,
handler,
&reconnect_auth,
&username,
)
.await
{
Ok(new_handle) => {
info!("reconnection successful");
{
let mut guard = reconnect_handle.lock().await;
*guard = new_handle;
}
for spec in &reconnect_remote_specs {
match RemoteForwarder::new(spec.clone()) {
Ok(rf) => {
let mut h = reconnect_handle.lock().await;
match rf.register(&mut h).await {
Ok(_) => {
debug!("re-registered remote forward: {}", spec)
}
Err(e) => warn!(
"failed to re-register remote forward {}: {e}",
spec
),
}
}
Err(e) => warn!("failed to create remote forwarder: {e}"),
}
}
attempts = 0;
}
Err(e) => {
warn!("reconnection attempt failed: {e}");
attempts += 1;
}
}
}
}
});
tokio::select! {
_ = wait_shutdown.changed() => {
if *wait_shutdown.borrow() {
info!("shutdown signal received");
}
}
_ = socks5_task => {
warn!("SOCKS5 server exited unexpectedly");
}
}
reconnect_monitor.abort();
#[cfg(unix)]
signal_done.abort();
self.shutdown().await?;
forward_task.abort();
let _ = forward_task.await;
Ok(())
}
pub async fn shutdown(&self) -> Result<(), ConnectError> {
info!("initiating graceful shutdown");
let _ = self.shutdown_tx.send(true);
{
let handle = self.handle.lock().await;
if !handle.is_closed() {
if let Err(e) = handle
.disconnect(russh::Disconnect::ByApplication, "shutdown", "")
.await
{
warn!("failed to send SSH disconnect: {e}");
}
}
}
tokio::time::sleep(DRAIN_TIMEOUT).await;
info!("graceful shutdown complete");
Ok(())
}
}
fn derive_username() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "alknet".to_string())
}
async fn establish_session<T: Transport>(
transport: &T,
handler: ClientHandler,
auth_config: &ClientAuthConfig,
username: &str,
) -> Result<client::Handle<ClientHandler>, ConnectError> {
let stream = transport.connect().await.map_err(|e| {
error!("transport connect failed: {e}");
ConnectError::ConnectionFailed
})?;
let config = Arc::new(client::Config::default());
let mut handle = client::connect_stream(config, stream, handler)
.await
.map_err(|e| {
error!("SSH connect failed: {e}");
ConnectError::ConnectionFailed
})?;
let auth_ok = auth_config
.authenticate(&mut handle, username)
.await
.map_err(|_| ConnectError::AuthFailed)?;
if !auth_ok {
return Err(ConnectError::AuthFailed);
}
Ok(handle)
}
fn backoff_duration(attempt: u32) -> Duration {
let secs: u64 = match attempt {
0 => 1,
1 => 2,
2 => 4,
3 => 8,
4 => 16,
_ => 30,
};
Duration::from_secs(secs)
}
fn build_local_forwarders(opts: &ConnectOptions) -> Result<Vec<LocalForwarder>, ConnectError> {
let mut forwarders = Vec::new();
for spec_str in &opts.forwards {
let spec = PortForwardSpec::local(spec_str).map_err(|e| {
warn!("invalid local forward spec '{}': {}", spec_str, e);
ConnectError::Config(ConfigError::InvalidFlag {
name: format!("invalid forward spec: {}", spec_str),
})
})?;
forwarders.push(LocalForwarder::new(spec).map_err(|e| {
warn!("failed to create local forwarder: {}", e);
ConnectError::ForwardFailed
})?);
}
Ok(forwarders)
}
fn build_remote_specs(opts: &ConnectOptions) -> Result<Vec<PortForwardSpec>, ConnectError> {
let mut specs = Vec::new();
for spec_str in &opts.remote_forwards {
let spec = PortForwardSpec::remote(spec_str).map_err(|e| {
warn!("invalid remote forward spec '{}': {}", spec_str, e);
ConnectError::Config(ConfigError::InvalidFlag {
name: format!("invalid remote forward spec: {}", spec_str),
})
})?;
specs.push(spec);
}
Ok(specs)
}
/// Errors that can occur during client connection setup and operation.
#[derive(Debug, thiserror::Error)]
pub enum ConnectError {
#[error("connection failed")]
ConnectionFailed,
#[error("authentication failed")]
AuthFailed,
#[error("forward setup failed")]
ForwardFailed,
#[error("config error: {0}")]
Config(#[from] ConfigError),
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::duplex;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
fn make_identity() -> KeySource {
KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec())
}
#[test]
fn connect_options_default_fields() {
let opts = ConnectOptions::new(make_identity());
assert!(opts.server.is_none());
assert!(opts.peer.is_none());
assert_eq!(opts.transport_mode, TransportMode::Tcp);
assert_eq!(opts.socks5_addr, "127.0.0.1:1080");
assert!(opts.forwards.is_empty());
assert!(opts.remote_forwards.is_empty());
assert!(opts.proxy.is_none());
assert!(opts.iroh_relay.is_none());
assert!(opts.tls_server_name.is_none());
assert!(!opts.insecure);
}
#[test]
fn connect_options_builder_pattern() {
let opts = ConnectOptions::new(make_identity())
.server("example.com:22")
.transport_mode(TransportMode::Tls)
.socks5_addr("127.0.0.1:9050")
.forward("127.0.0.1:5432:db:5432")
.remote_forward("0.0.0.0:8080:127.0.0.1:3000")
.proxy("socks5://127.0.0.1:1080")
.iroh_relay("https://relay.example.com")
.tls_server_name("alknet.test")
.insecure(true);
assert_eq!(opts.server.as_deref(), Some("example.com:22"));
assert_eq!(opts.transport_mode, TransportMode::Tls);
assert_eq!(opts.socks5_addr, "127.0.0.1:9050");
assert_eq!(opts.forwards.len(), 1);
assert_eq!(opts.remote_forwards.len(), 1);
assert_eq!(opts.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
assert_eq!(
opts.iroh_relay.as_deref(),
Some("https://relay.example.com")
);
assert_eq!(opts.tls_server_name.as_deref(), Some("alknet.test"));
assert!(opts.insecure);
}
#[test]
fn connect_options_validate_tcp_requires_server() {
let opts = ConnectOptions::new(make_identity()).transport_mode(TransportMode::Tcp);
assert!(opts.validate().is_err());
}
#[test]
fn connect_options_validate_tcp_with_server_ok() {
let opts = ConnectOptions::new(make_identity()).server("example.com:22");
assert!(opts.validate().is_ok());
}
#[test]
fn connect_options_validate_tls_requires_server() {
let opts = ConnectOptions::new(make_identity()).transport_mode(TransportMode::Tls);
assert!(opts.validate().is_err());
}
#[test]
fn connect_options_validate_tls_with_server_ok() {
let opts = ConnectOptions::new(make_identity())
.transport_mode(TransportMode::Tls)
.server("example.com:443");
assert!(opts.validate().is_ok());
}
#[test]
fn connect_options_validate_iroh_requires_peer() {
let opts = ConnectOptions::new(make_identity()).transport_mode(TransportMode::Iroh);
assert!(opts.validate().is_err());
}
#[test]
fn connect_options_validate_iroh_with_peer_ok() {
let opts = ConnectOptions::new(make_identity())
.transport_mode(TransportMode::Iroh)
.peer("some-endpoint-id");
assert!(opts.validate().is_ok());
}
#[test]
fn identity_accepts_key_source_file() {
let file_source = KeySource::File(std::path::PathBuf::from("/path/to/key"));
let opts = ConnectOptions::new(file_source);
match &opts.identity {
KeySource::File(p) => assert_eq!(p, &std::path::PathBuf::from("/path/to/key")),
_ => panic!("expected File variant"),
}
}
#[test]
fn identity_accepts_key_source_memory() {
let mem_source = KeySource::Memory(b"key-data".to_vec());
let opts = ConnectOptions::new(mem_source);
match &opts.identity {
KeySource::Memory(d) => assert_eq!(d, b"key-data"),
_ => panic!("expected Memory variant"),
}
}
#[test]
fn transport_mode_display() {
assert_eq!(TransportMode::Tcp.to_string(), "tcp");
assert_eq!(TransportMode::Tls.to_string(), "tls");
assert_eq!(TransportMode::Iroh.to_string(), "iroh");
}
#[test]
fn connect_error_variants() {
assert_eq!(
ConnectError::ConnectionFailed.to_string(),
"connection failed"
);
assert_eq!(
ConnectError::AuthFailed.to_string(),
"authentication failed"
);
assert_eq!(
ConnectError::ForwardFailed.to_string(),
"forward setup failed"
);
}
#[test]
fn connect_options_debug_redacts_identity() {
let opts = ConnectOptions::new(make_identity());
let debug_str = format!("{:?}", opts);
assert!(debug_str.contains("<KeySource>"));
assert!(!debug_str.contains("OPENSSH"));
}
struct FailTransport;
#[async_trait::async_trait]
impl Transport for FailTransport {
type Stream = tokio::io::DuplexStream;
async fn connect(&self) -> anyhow::Result<Self::Stream> {
Err(anyhow::anyhow!("always fails"))
}
fn describe(&self) -> String {
"fail".to_string()
}
}
struct DuplexTransport {
connect_count: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl Transport for DuplexTransport {
type Stream = tokio::io::DuplexStream;
async fn connect(&self) -> anyhow::Result<Self::Stream> {
self.connect_count.fetch_add(1, Ordering::SeqCst);
let (client, _) = duplex(4096);
Ok(client)
}
fn describe(&self) -> String {
"duplex".to_string()
}
}
#[tokio::test]
async fn client_session_new_transport_fails() {
let opts = ConnectOptions::new(make_identity()).server("example.com:22");
let transport = Arc::new(FailTransport);
let result = ClientSession::new(opts, transport).await;
assert!(result.is_err());
assert!(matches!(
result.err().unwrap(),
ConnectError::ConnectionFailed
));
}
#[tokio::test]
async fn client_session_new_ssh_handshake_fails() {
let transport = Arc::new(DuplexTransport {
connect_count: Arc::new(AtomicUsize::new(0)),
});
let opts = ConnectOptions::new(make_identity()).server("example.com:22");
let result = ClientSession::new(opts, transport).await;
assert!(result.is_err());
assert!(matches!(
result.err().unwrap(),
ConnectError::ConnectionFailed
));
}
#[test]
fn build_local_forwarders_empty() {
let opts = ConnectOptions::new(make_identity());
let result = build_local_forwarders(&opts);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn build_local_forwarders_valid() {
let opts = ConnectOptions::new(make_identity()).forward("127.0.0.1:5432:db:5432");
let result = build_local_forwarders(&opts);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 1);
}
#[test]
fn build_local_forwarders_invalid_spec() {
let opts = ConnectOptions::new(make_identity()).forward("bad-spec");
let result = build_local_forwarders(&opts);
assert!(result.is_err());
}
#[test]
fn build_remote_specs_empty() {
let opts = ConnectOptions::new(make_identity());
let result = build_remote_specs(&opts);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn build_remote_specs_valid() {
let opts =
ConnectOptions::new(make_identity()).remote_forward("0.0.0.0:8080:127.0.0.1:3000");
let result = build_remote_specs(&opts);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 1);
}
#[test]
fn build_remote_specs_invalid() {
let opts = ConnectOptions::new(make_identity()).remote_forward("bad");
let result = build_remote_specs(&opts);
assert!(result.is_err());
}
#[test]
fn default_socks5_addr() {
assert_eq!(DEFAULT_SOCKS5_ADDR, "127.0.0.1:1080");
}
#[test]
fn drain_timeout_is_two_seconds() {
assert_eq!(DRAIN_TIMEOUT, Duration::from_secs(2));
}
#[test]
fn transport_mode_equality() {
assert_eq!(TransportMode::Tcp, TransportMode::Tcp);
assert_ne!(TransportMode::Tcp, TransportMode::Tls);
assert_ne!(TransportMode::Tls, TransportMode::Iroh);
}
#[tokio::test]
async fn shutdown_sends_disconnect_and_drains() {
let transport = Arc::new(DuplexTransport {
connect_count: Arc::new(AtomicUsize::new(0)),
});
let opts = ConnectOptions::new(make_identity()).server("example.com:22");
let result = ClientSession::new(opts, transport).await;
assert!(result.is_err());
}
#[test]
fn socks5_is_always_enabled_by_default() {
let opts = ConnectOptions::new(make_identity());
assert!(!opts.socks5_addr.is_empty());
}
#[tokio::test]
async fn integration_mock_transport_session() {
use crate::socks5::{ChannelOpenError, ChannelOpener};
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
struct MockOpener;
impl ChannelOpener for MockOpener {
type Stream = tokio::io::DuplexStream;
async fn open_channel(
&self,
_host: String,
_port: u16,
) -> Result<Self::Stream, ChannelOpenError> {
let (client, _server) = duplex(4096);
Ok(client)
}
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bound_addr = listener.local_addr().unwrap();
drop(listener);
let opener = MockOpener;
let server = Socks5Server::with_addr(opener, &bound_addr.to_string());
let _server_task = tokio::spawn(async move {
let _ = server.run().await;
});
tokio::time::sleep(Duration::from_millis(50)).await;
let mut conn = TcpStream::connect(bound_addr).await.unwrap();
let greeting = [0x05, 0x01, 0x00];
conn.write_all(&greeting).await.unwrap();
let mut auth_resp = [0u8; 2];
conn.read_exact(&mut auth_resp).await.unwrap();
assert_eq!(auth_resp, [0x05, 0x00]);
let connect_req = [0x05, 0x01, 0x00, 0x01, 127, 0, 0, 1, 0, 80];
conn.write_all(&connect_req).await.unwrap();
let mut reply = [0u8; 10];
conn.read_exact(&mut reply).await.unwrap();
assert_eq!(reply[1], 0x00);
conn.write_all(b"test data").await.unwrap();
conn.shutdown().await.unwrap();
}
}

View File

@@ -1,529 +0,0 @@
//! Local and remote port forwarding.
//!
//! `LocalForwarder` binds a local TCP listener and forwards each connection through
//! an SSH `direct-tcpip` channel. `RemoteForwarder` requests `tcpip-forward` from
//! the server and handles `forwarded-tcpip` channels. Specs follow the
//! `bind_addr:bind_port:target_host:target_port` format.
use std::net::SocketAddr;
use std::sync::Arc;
use russh::client;
use tokio::io;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
use tracing::{debug, error, info};
use crate::error::ForwardError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PortForwardSpecKind {
Local,
Remote,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortForwardSpec {
pub kind: PortForwardSpecKind,
pub bind_addr: String,
pub bind_port: u16,
pub target_host: String,
pub target_port: u16,
}
impl PortForwardSpec {
pub fn local(spec: &str) -> Result<Self, ForwardError> {
let (bind_addr, bind_port, target_host, target_port) = parse_spec(spec)?;
Ok(Self {
kind: PortForwardSpecKind::Local,
bind_addr,
bind_port,
target_host,
target_port,
})
}
pub fn remote(spec: &str) -> Result<Self, ForwardError> {
let (bind_addr, bind_port, target_host, target_port) = parse_spec(spec)?;
Ok(Self {
kind: PortForwardSpecKind::Remote,
bind_addr,
bind_port,
target_host,
target_port,
})
}
pub fn listen_addr(&self) -> Result<SocketAddr, ForwardError> {
format!("{}:{}", self.bind_addr, self.bind_port)
.parse()
.map_err(|_| ForwardError::InvalidSpec {
spec: format!("{}:{}", self.bind_addr, self.bind_port),
})
}
pub fn target_addr(&self) -> Result<SocketAddr, ForwardError> {
format!("{}:{}", self.target_host, self.target_port)
.parse()
.map_err(|_| ForwardError::InvalidSpec {
spec: format!("{}:{}", self.target_host, self.target_port),
})
}
}
impl std::fmt::Display for PortForwardSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let prefix = match self.kind {
PortForwardSpecKind::Local => "-L",
PortForwardSpecKind::Remote => "-R",
};
write!(
f,
"{} {}:{}:{}:{}",
prefix, self.bind_addr, self.bind_port, self.target_host, self.target_port
)
}
}
fn parse_spec(spec: &str) -> Result<(String, u16, String, u16), ForwardError> {
let parts: Vec<&str> = spec.split(':').collect();
if parts.len() != 4 {
return Err(ForwardError::InvalidSpec {
spec: spec.to_string(),
});
}
let bind_addr = parts[0].to_string();
let bind_port: u16 = parts[1].parse().map_err(|_| ForwardError::InvalidSpec {
spec: spec.to_string(),
})?;
let target_host = parts[2].to_string();
let target_port: u16 = parts[3].parse().map_err(|_| ForwardError::InvalidSpec {
spec: spec.to_string(),
})?;
Ok((bind_addr, bind_port, target_host, target_port))
}
pub struct LocalForwarder {
spec: PortForwardSpec,
listener: Option<TcpListener>,
}
impl LocalForwarder {
pub fn new(spec: PortForwardSpec) -> Result<Self, ForwardError> {
if spec.kind != PortForwardSpecKind::Local {
return Err(ForwardError::InvalidSpec {
spec: format!("expected local spec, got {:?}", spec.kind),
});
}
Ok(Self {
spec,
listener: None,
})
}
pub fn spec(&self) -> &PortForwardSpec {
&self.spec
}
pub async fn run<H: client::Handler + Send + 'static>(
&mut self,
handle: Arc<Mutex<client::Handle<H>>>,
) -> Result<(), ForwardError> {
let listen_addr = self.spec.listen_addr()?;
let listener: TcpListener = TcpListener::bind(listen_addr)
.await
.map_err(|e| ForwardError::BindFailed { source: e })?;
self.listener = Some(listener);
let remote_host = self.spec.target_host.clone();
let remote_port = self.spec.target_port;
info!(
"local forward listening on {} -> {}:{}",
listen_addr, remote_host, remote_port
);
loop {
let listener = match &self.listener {
Some(l) => l,
None => return Ok(()),
};
let accept_result = listener.accept().await;
let (local_stream, local_addr) = match accept_result {
Ok(conn) => conn,
Err(e) => {
let handle = handle.lock().await;
if handle.is_closed() {
debug!("local forward accept loop ending: ssh session closed");
return Ok(());
}
drop(handle);
error!("local forward accept error: {}", e);
continue;
}
};
debug!(
"local forward connection from {} -> {}:{}",
local_addr, remote_host, remote_port
);
let handle = handle.clone();
let remote_host = remote_host.clone();
tokio::spawn(async move {
if let Err(e) =
proxy_local_to_remote(local_stream, handle, &remote_host, remote_port).await
{
debug!("local forward proxy error: {}", e);
}
});
}
}
pub async fn stop(&mut self) {
if let Some(listener) = self.listener.take() {
drop(listener);
}
}
pub fn local_port(&self) -> u16 {
self.spec.bind_port
}
}
async fn proxy_local_to_remote<H: client::Handler + Send + 'static>(
local_stream: TcpStream,
handle: Arc<Mutex<client::Handle<H>>>,
remote_host: &str,
remote_port: u16,
) -> Result<(), ForwardError> {
let local_addr = local_stream
.peer_addr()
.map(|a| a.to_string())
.unwrap_or_default();
let handle_guard = handle.lock().await;
let channel = handle_guard
.channel_open_direct_tcpip(remote_host, remote_port as u32, &local_addr, 0)
.await
.map_err(|e| ForwardError::ChannelOpenFailed {
source: Box::new(e) as _,
})?;
drop(handle_guard);
let ssh_stream = channel.into_stream();
let (mut ssh_read, mut ssh_write) = tokio::io::split(ssh_stream);
let (mut local_read, mut local_write) = tokio::io::split(local_stream);
let client_to_server = io::copy(&mut local_read, &mut ssh_write);
let server_to_client = io::copy(&mut ssh_read, &mut local_write);
match tokio::join!(client_to_server, server_to_client) {
(Err(e), _) | (_, Err(e)) => {
debug!("local forward bidirectional copy error: {}", e);
}
_ => {}
}
Ok(())
}
pub struct RemoteForwarder {
spec: PortForwardSpec,
cancel: Option<tokio::sync::oneshot::Sender<()>>,
}
impl RemoteForwarder {
pub fn new(spec: PortForwardSpec) -> Result<Self, ForwardError> {
if spec.kind != PortForwardSpecKind::Remote {
return Err(ForwardError::InvalidSpec {
spec: format!("expected remote spec, got {:?}", spec.kind),
});
}
Ok(Self { spec, cancel: None })
}
pub fn spec(&self) -> &PortForwardSpec {
&self.spec
}
pub async fn register<H: client::Handler + Send + 'static>(
&self,
handle: &mut client::Handle<H>,
) -> Result<u32, ForwardError> {
let port = handle
.tcpip_forward(&self.spec.bind_addr, self.spec.bind_port as u32)
.await
.map_err(|e| ForwardError::ChannelOpenFailed {
source: Box::new(e) as _,
})?;
Ok(port)
}
pub async fn handle_forwarded_channel(
channel: russh::Channel<russh::client::Msg>,
connected_address: &str,
connected_port: u32,
local_host: &str,
local_port: u16,
) {
debug!(
"remote forward: server opened forwarded-tcpip channel to {}:{} -> local {}:{}",
connected_address, connected_port, local_host, local_port
);
let local_target = format!("{}:{}", local_host, local_port);
let local_stream = match TcpStream::connect(&local_target).await {
Ok(s) => s,
Err(e) => {
error!(
"remote forward: failed to connect to local target {}: {}",
local_target, e
);
return;
}
};
let ssh_stream = channel.into_stream();
let (mut ssh_read, mut ssh_write) = tokio::io::split(ssh_stream);
let (mut local_read, mut local_write) = tokio::io::split(local_stream);
let client_to_server = io::copy(&mut local_read, &mut ssh_write);
let server_to_client = io::copy(&mut ssh_read, &mut local_write);
match tokio::join!(client_to_server, server_to_client) {
(Err(e), _) | (_, Err(e)) => {
debug!("remote forward bidirectional copy error: {}", e);
}
_ => {}
}
}
pub async fn unregister<H: client::Handler + Send + 'static>(
&self,
handle: &client::Handle<H>,
) -> Result<(), ForwardError> {
handle
.cancel_tcpip_forward(&self.spec.bind_addr, self.spec.bind_port as u32)
.await
.map_err(|e| ForwardError::ChannelOpenFailed {
source: Box::new(e) as _,
})?;
Ok(())
}
pub async fn stop(&mut self) {
if let Some(cancel) = self.cancel.take() {
let _ = cancel.send(());
}
}
}
pub async fn run_local_forwarders<H: client::Handler + Send + 'static>(
forwarders: Vec<LocalForwarder>,
handle: Arc<Mutex<client::Handle<H>>>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) -> Vec<LocalForwarder> {
let mut forwarders = forwarders;
let mut tasks = Vec::new();
for forwarder in forwarders.drain(..) {
let handle = handle.clone();
let spec = forwarder.spec().clone();
let (_cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
tasks.push(tokio::spawn(async move {
let mut fwd = forwarder;
tokio::select! {
result = fwd.run(handle) => {
if let Err(e) = result {
error!("local forward {} failed: {}", spec, e);
}
}
_ = cancel_rx => {
fwd.stop().await;
}
}
fwd
}));
}
let _ = shutdown.changed().await;
for task in &tasks {
task.abort();
}
let mut results = Vec::new();
for task in tasks {
match task.await {
Ok(fwd) => results.push(fwd),
Err(e) => {
if !e.is_cancelled() {
error!("local forwarder task panicked: {}", e);
}
}
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_local_spec() {
let spec = PortForwardSpec::local("127.0.0.1:5432:db.internal:5432").unwrap();
assert_eq!(spec.kind, PortForwardSpecKind::Local);
assert_eq!(spec.bind_addr, "127.0.0.1");
assert_eq!(spec.bind_port, 5432);
assert_eq!(spec.target_host, "db.internal");
assert_eq!(spec.target_port, 5432);
}
#[test]
fn parse_remote_spec() {
let spec = PortForwardSpec::remote("0.0.0.0:8080:127.0.0.1:3000").unwrap();
assert_eq!(spec.kind, PortForwardSpecKind::Remote);
assert_eq!(spec.bind_addr, "0.0.0.0");
assert_eq!(spec.bind_port, 8080);
assert_eq!(spec.target_host, "127.0.0.1");
assert_eq!(spec.target_port, 3000);
}
#[test]
fn parse_spec_invalid_few_parts() {
assert!(PortForwardSpec::local("127.0.0.1:5432:db").is_err());
}
#[test]
fn parse_spec_invalid_many_parts() {
assert!(PortForwardSpec::local("a:b:c:d:e").is_err());
}
#[test]
fn parse_spec_invalid_port() {
assert!(PortForwardSpec::local("127.0.0.1:abc:db:5432").is_err());
}
#[test]
fn parse_spec_invalid_target_port() {
assert!(PortForwardSpec::local("127.0.0.1:5432:db:abc").is_err());
}
#[test]
fn spec_display() {
let spec = PortForwardSpec::local("127.0.0.1:5432:db.internal:5432").unwrap();
assert_eq!(spec.to_string(), "-L 127.0.0.1:5432:db.internal:5432");
}
#[test]
fn spec_display_remote() {
let spec = PortForwardSpec::remote("0.0.0.0:8080:127.0.0.1:3000").unwrap();
assert_eq!(spec.to_string(), "-R 0.0.0.0:8080:127.0.0.1:3000");
}
#[test]
fn local_forwarder_rejects_remote_spec() {
let spec = PortForwardSpec::remote("0.0.0.0:8080:127.0.0.1:3000").unwrap();
assert!(LocalForwarder::new(spec).is_err());
}
#[test]
fn remote_forwarder_rejects_local_spec() {
let spec = PortForwardSpec::local("127.0.0.1:5432:db.internal:5432").unwrap();
assert!(RemoteForwarder::new(spec).is_err());
}
#[test]
fn listen_addr_valid() {
let spec = PortForwardSpec::local("127.0.0.1:5432:db.internal:5432").unwrap();
let addr = spec.listen_addr().unwrap();
assert_eq!(addr.port(), 5432);
}
#[test]
fn listen_addr_invalid_host() {
let spec = PortForwardSpec {
kind: PortForwardSpecKind::Local,
bind_addr: "!!!invalid".to_string(),
bind_port: 5432,
target_host: "db".to_string(),
target_port: 5432,
};
assert!(spec.listen_addr().is_err());
}
#[tokio::test]
async fn local_forward_bind_and_accept() {
let spec = PortForwardSpec::local(&format!("127.0.0.1:0:remote:5432")).unwrap();
let forwarder = LocalForwarder::new(spec).unwrap();
let listen_addr = forwarder.spec.listen_addr().unwrap();
let listener = TcpListener::bind(listen_addr).await.unwrap();
let bound_addr = listener.local_addr().unwrap();
drop(listener);
let spec = PortForwardSpec::local(&format!("127.0.0.1:{}:remote:5432", bound_addr.port()))
.unwrap();
let forwarder = LocalForwarder::new(spec).unwrap();
assert_eq!(forwarder.local_port(), bound_addr.port());
}
#[tokio::test]
async fn remote_forward_proxy_bidirectional() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let echo_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let _echo_addr = echo_listener.local_addr().unwrap();
let echo_server = tokio::spawn(async move {
let (mut stream, _) = echo_listener.accept().await.unwrap();
let mut buf = [0u8; 64];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
if stream.write_all(&buf[..n]).await.is_err() {
break;
}
}
});
let local_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let local_addr = local_listener.local_addr().unwrap();
let proxy_task = tokio::spawn(async move {
let (stream, _) = local_listener.accept().await.unwrap();
let (mut read, mut write) = tokio::io::split(stream);
let _ = io::copy(&mut read, &mut write).await;
});
let mut local_conn = TcpStream::connect(local_addr).await.unwrap();
local_conn.write_all(b"hello").await.unwrap();
let mut buf = [0u8; 64];
let n = local_conn.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"hello");
echo_server.abort();
proxy_task.abort();
}
#[test]
fn forwarder_spec_access() {
let spec = PortForwardSpec::local("127.0.0.1:5432:db.internal:5432").unwrap();
let forwarder = LocalForwarder::new(spec.clone()).unwrap();
assert_eq!(forwarder.spec(), &spec);
assert_eq!(forwarder.local_port(), 5432);
}
#[test]
fn remote_forwarder_spec_access() {
let spec = PortForwardSpec::remote("0.0.0.0:8080:127.0.0.1:3000").unwrap();
let forwarder = RemoteForwarder::new(spec.clone()).unwrap();
assert_eq!(forwarder.spec(), &spec);
}
}

View File

@@ -1,17 +0,0 @@
//! Client-side SSH session management.
//!
//! Provides `ClientSession` for establishing an SSH connection over any transport,
//! running a local SOCKS5 proxy, and managing port forwards. Also provides
//! `ChannelManager` for programmatic channel management with automatic reconnection.
//!
//! The client always starts a SOCKS5 proxy (default `127.0.0.1:1080`) when running
//! via `ClientSession::run()`. For VPN-like "route all traffic" behavior, use
//! [tun2proxy](https://github.com/tun2proxy/tun2proxy) alongside the SOCKS5 proxy.
pub mod channel_manager;
pub mod connect;
pub mod forward;
pub use channel_manager::{ChannelManager, ForwardRequest};
pub use connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
pub use forward::{LocalForwarder, PortForwardSpec, PortForwardSpecKind, RemoteForwarder};

View File

@@ -0,0 +1,714 @@
//! Configuration: `DynamicConfig`, `AuthPolicy`, `ApiKeyEntry`,
//! `RateLimitConfig`, `ConfigReloadHandle`.
//!
//! See `docs/architecture/crates/core/config.md` for the full specification.
//!
//! This module provides the dynamic-config types required by
//! `auth::ConfigIdentityProvider`. The remaining types (`StaticConfig`,
//! `TlsIdentity`, `ConfigError`) are filled in by the core/config task.
use std::collections::HashMap;
use std::io;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use crate::auth::Identity;
pub const API_KEY_PREFIX: &str = "alk_";
#[derive(Debug, Clone)]
pub struct StaticConfig {
pub listen_addr: Option<SocketAddr>,
pub tls_identity: Option<TlsIdentity>,
#[cfg(feature = "iroh")]
pub iroh_relay: Option<iroh::RelayUrl>,
pub drain_timeout: Duration,
}
#[derive(Clone)]
pub struct Ed25519SecretKey(ed25519_dalek::SigningKey);
impl Ed25519SecretKey {
pub fn generate() -> Self {
let mut csprng = rand::rngs::OsRng;
Self(ed25519_dalek::SigningKey::generate(&mut csprng))
}
pub fn from_bytes(bytes: &[u8; 32]) -> Self {
Self(ed25519_dalek::SigningKey::from_bytes(bytes))
}
pub fn as_bytes(&self) -> [u8; 32] {
self.0.to_bytes()
}
pub fn public(&self) -> ed25519_dalek::VerifyingKey {
self.0.verifying_key()
}
pub fn sign(&self, message: &[u8]) -> ed25519_dalek::Signature {
use ed25519_dalek::Signer;
self.0.sign(message)
}
}
impl std::fmt::Debug for Ed25519SecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ed25519SecretKey").finish_non_exhaustive()
}
}
impl zeroize::ZeroizeOnDrop for Ed25519SecretKey {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcmeDirectory {
Production,
Staging,
Custom(String),
}
impl AcmeDirectory {
pub fn url(&self) -> &str {
match self {
AcmeDirectory::Production => "https://acme-v02.api.letsencrypt.org/directory",
AcmeDirectory::Staging => "https://acme-staging-v02.api.letsencrypt.org/directory",
AcmeDirectory::Custom(url) => url,
}
}
}
#[derive(Debug, Clone)]
pub enum TlsIdentity {
X509 {
cert: PathBuf,
key: PathBuf,
},
RawKey(Ed25519SecretKey),
SelfSigned,
Acme {
domains: Vec<String>,
cache_dir: PathBuf,
directory: AcmeDirectory,
contact: Vec<String>,
},
}
#[derive(Debug, Clone, Default)]
pub struct DynamicConfig {
pub auth: AuthPolicy,
pub rate_limits: RateLimitConfig,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PeerEntry {
pub peer_id: String,
pub fingerprints: Vec<String>,
pub auth_token_hash: Option<String>,
pub scopes: Vec<String>,
pub resources: HashMap<String, Vec<String>>,
pub display_name: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, Default)]
pub struct AuthPolicy {
pub peers: Vec<PeerEntry>,
pub api_keys: Vec<ApiKeyEntry>,
}
#[derive(Debug, Clone)]
pub struct ApiKeyEntry {
pub prefix: String,
pub hash: String,
pub scopes: Vec<String>,
pub description: String,
pub expires_at: Option<u64>,
}
impl AuthPolicy {
pub fn empty() -> Self {
Self::default()
}
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
self.peers
.iter()
.find(|p| p.enabled && p.fingerprints.iter().any(|f| f == fingerprint))
.map(|p| Identity {
id: p.peer_id.clone(),
scopes: p.scopes.clone(),
resources: p.resources.clone(),
})
}
pub fn resolve_identity_from_token(&self, token: &str) -> Option<Identity> {
let token_hash = sha256_hex(token);
self.peers
.iter()
.find(|p| p.enabled && p.auth_token_hash.as_deref() == Some(&token_hash))
.map(|p| Identity {
id: p.peer_id.clone(),
scopes: p.scopes.clone(),
resources: p.resources.clone(),
})
.or_else(|| self.resolve_api_key(token))
}
pub fn validate_peer_ids(&self) -> Result<(), DuplicatePeerId> {
let mut seen = std::collections::HashSet::new();
for peer in &self.peers {
if !seen.insert(peer.peer_id.as_str()) {
return Err(DuplicatePeerId {
peer_id: peer.peer_id.clone(),
});
}
}
Ok(())
}
pub fn resolve_api_key(&self, token: &str) -> Option<Identity> {
if !token.starts_with(API_KEY_PREFIX) {
return None;
}
let prefix_part = &token[..token.len().min(8)];
let entry = self
.api_keys
.iter()
.find(|e| prefix_part.starts_with(&e.prefix))?;
let expected_hash = sha256_hex(token);
if entry.hash != expected_hash {
return None;
}
if let Some(expires_at) = entry.expires_at {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now_secs >= expires_at {
return None;
}
}
Some(Identity {
id: entry.prefix.clone(),
scopes: entry.scopes.clone(),
resources: std::collections::HashMap::new(),
})
}
}
fn sha256_hex(input: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
format!("sha256:{}", hex::encode(result))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("duplicate peer_id: {peer_id}")]
pub struct DuplicatePeerId {
pub peer_id: String,
}
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub max_connections_per_ip: usize,
pub max_auth_attempts: usize,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_connections_per_ip: 100,
max_auth_attempts: 5,
}
}
}
pub struct ConfigReloadHandle {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigReloadHandle {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
Self { dynamic }
}
pub fn reload(&self, new_config: DynamicConfig) {
self.dynamic.store(Arc::new(new_config));
}
pub fn dynamic(&self) -> Arc<DynamicConfig> {
self.dynamic.load_full()
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("invalid flag: {name}")]
InvalidFlag { name: String },
#[error("key file not found: {path}")]
KeyFileNotFound { path: String },
#[error("bind failed: {0}")]
BindFailed(#[from] io::Error),
#[error("tls config error: {0}")]
TlsConfig(io::Error),
#[error("incompatible options")]
IncompatibleOptions,
}
impl Default for StaticConfig {
fn default() -> Self {
Self {
listen_addr: None,
tls_identity: None,
#[cfg(feature = "iroh")]
iroh_relay: None,
drain_timeout: Duration::from_secs(2),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_config_default() {
let cfg = StaticConfig::default();
assert!(cfg.listen_addr.is_none());
assert!(cfg.tls_identity.is_none());
assert_eq!(cfg.drain_timeout, Duration::from_secs(2));
}
#[test]
fn dynamic_config_default() {
let cfg = DynamicConfig::default();
assert!(cfg.auth.peers.is_empty());
assert!(cfg.auth.api_keys.is_empty());
assert_eq!(cfg.rate_limits.max_connections_per_ip, 100);
assert_eq!(cfg.rate_limits.max_auth_attempts, 5);
}
#[test]
fn auth_policy_default() {
let policy = AuthPolicy::default();
assert!(policy.peers.is_empty());
assert!(policy.api_keys.is_empty());
}
#[test]
fn rate_limit_config_default() {
let rl = RateLimitConfig::default();
assert!(rl.max_connections_per_ip > 0);
assert!(rl.max_auth_attempts > 0);
}
#[test]
fn api_key_entry_construct() {
let entry = ApiKeyEntry {
prefix: "alk12345".to_string(),
hash: "deadbeef".to_string(),
scopes: vec!["admin".to_string()],
description: "test key".to_string(),
expires_at: Some(1_700_000_000),
};
assert_eq!(entry.prefix, "alk12345");
assert_eq!(entry.scopes, vec!["admin"]);
assert_eq!(entry.expires_at, Some(1_700_000_000));
}
#[test]
fn tls_identity_x509_construct() {
let id = TlsIdentity::X509 {
cert: PathBuf::from("/etc/cert.pem"),
key: PathBuf::from("/etc/key.pem"),
};
match id {
TlsIdentity::X509 { cert, key } => {
assert_eq!(cert, PathBuf::from("/etc/cert.pem"));
assert_eq!(key, PathBuf::from("/etc/key.pem"));
}
_ => panic!("expected X509"),
}
}
#[test]
fn tls_identity_self_signed() {
let id = TlsIdentity::SelfSigned;
let s = format!("{id:?}");
assert!(s.contains("SelfSigned"));
}
#[test]
fn config_reload_handle_swaps_atomically() {
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
let handle = ConfigReloadHandle::new(dynamic.clone());
let initial = handle.dynamic();
assert!(initial.auth.peers.is_empty());
let new_auth = AuthPolicy {
peers: vec![PeerEntry {
peer_id: "worker-a".to_string(),
fingerprints: vec!["aa:bb:cc".to_string()],
auth_token_hash: None,
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
display_name: None,
enabled: true,
}],
api_keys: Vec::new(),
};
let new_config = DynamicConfig {
auth: new_auth,
rate_limits: RateLimitConfig::default(),
};
handle.reload(new_config);
let after = handle.dynamic();
assert_eq!(after.auth.peers.len(), 1);
assert_eq!(after.auth.peers[0].peer_id, "worker-a");
assert!(initial.auth.peers.is_empty());
}
#[test]
fn config_reload_handle_dynamic_returns_current() {
let dynamic = Arc::new(ArcSwap::from_pointee(DynamicConfig::default()));
let handle = ConfigReloadHandle::new(dynamic);
let a = handle.dynamic();
let b = handle.dynamic();
assert_eq!(
a.rate_limits.max_auth_attempts,
b.rate_limits.max_auth_attempts
);
}
#[test]
fn config_error_invalid_flag_display() {
let e = ConfigError::InvalidFlag {
name: "foo".to_string(),
};
assert_eq!(format!("{e}"), "invalid flag: foo");
}
#[test]
fn config_error_key_file_not_found_display() {
let e = ConfigError::KeyFileNotFound {
path: "/x".to_string(),
};
assert_eq!(format!("{e}"), "key file not found: /x");
}
#[test]
fn config_error_incompatible_options_display() {
let e = ConfigError::IncompatibleOptions;
assert_eq!(format!("{e}"), "incompatible options");
}
#[test]
fn config_error_bind_failed_from_io() {
let io_err = io::Error::new(io::ErrorKind::AddrInUse, "busy");
let e: ConfigError = io_err.into();
assert!(matches!(e, ConfigError::BindFailed(_)));
}
#[test]
fn config_error_tls_config_display() {
let e = ConfigError::TlsConfig(io::Error::new(io::ErrorKind::InvalidData, "bad"));
let s = format!("{e}");
assert!(s.starts_with("tls config error:"));
}
#[test]
fn resolve_api_key_returns_empty_resources() {
let token = "alk_test_secret";
let hash = sha256_hex(token);
let entry = ApiKeyEntry {
prefix: "alk_tes".to_string(),
hash,
scopes: vec!["admin".to_string()],
description: "test key".to_string(),
expires_at: None,
};
let policy = AuthPolicy {
peers: Vec::new(),
api_keys: vec![entry],
};
let identity = policy.resolve_api_key(token);
assert!(
identity.is_some(),
"api key with matching prefix and hash should resolve"
);
let identity = identity.unwrap();
assert_eq!(identity.id, "alk_tes");
assert_eq!(identity.scopes, vec!["admin"]);
assert!(
identity.resources.is_empty(),
"token-resolved identities must have empty resources (Option B — scopes only)"
);
}
#[test]
fn resolve_identity_from_fingerprint_uses_peer_id() {
let policy = AuthPolicy {
peers: vec![PeerEntry {
peer_id: "worker-a".to_string(),
fingerprints: vec!["SHA256:known".to_string()],
auth_token_hash: None,
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
display_name: None,
enabled: true,
}],
api_keys: vec![],
};
let identity = policy
.resolve_identity_from_fingerprint("SHA256:known")
.expect("known fingerprint should resolve");
assert_eq!(identity.id, "worker-a");
assert_eq!(identity.scopes, vec!["relay:connect"]);
}
// --- PeerEntry model (ADR-030) ---------------------------------------
fn peer_entry(peer_id: &str, fingerprints: &[&str]) -> PeerEntry {
PeerEntry {
peer_id: peer_id.to_string(),
fingerprints: fingerprints.iter().map(|s| s.to_string()).collect(),
auth_token_hash: None,
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
display_name: None,
enabled: true,
}
}
#[test]
fn fingerprint_resolution_known_returns_some_with_peer_id() {
let policy = AuthPolicy {
peers: vec![peer_entry("worker-a", &["ed25519:abc"])],
api_keys: vec![],
};
let identity = policy
.resolve_identity_from_fingerprint("ed25519:abc")
.expect("known fingerprint resolves");
assert_eq!(identity.id, "worker-a");
assert_eq!(identity.scopes, vec!["relay:connect"]);
}
#[test]
fn fingerprint_resolution_unknown_returns_none() {
let policy = AuthPolicy {
peers: vec![peer_entry("worker-a", &["ed25519:abc"])],
api_keys: vec![],
};
assert!(policy
.resolve_identity_from_fingerprint("ed25519:unknown")
.is_none());
}
#[test]
fn fingerprint_resolution_disabled_returns_none() {
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
entry.enabled = false;
let policy = AuthPolicy {
peers: vec![entry],
api_keys: vec![],
};
assert!(policy
.resolve_identity_from_fingerprint("ed25519:abc")
.is_none());
}
#[test]
fn token_resolution_matching_peer_returns_some_with_peer_id() {
let token = "bearer-secret";
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
entry.auth_token_hash = Some(sha256_hex(token));
let policy = AuthPolicy {
peers: vec![entry],
api_keys: vec![],
};
let identity = policy
.resolve_identity_from_token(token)
.expect("matching auth_token_hash resolves");
assert_eq!(identity.id, "worker-a");
}
#[test]
fn token_resolution_non_matching_falls_through_to_api_key() {
let api_token = "alk_test_secret";
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
entry.auth_token_hash = Some(sha256_hex("different-token"));
let api_entry = ApiKeyEntry {
prefix: "alk_tes".to_string(),
hash: sha256_hex(api_token),
scopes: vec!["admin".to_string()],
description: "test key".to_string(),
expires_at: None,
};
let policy = AuthPolicy {
peers: vec![entry],
api_keys: vec![api_entry],
};
let identity = policy
.resolve_identity_from_token(api_token)
.expect("api key fall-through resolves");
assert_eq!(identity.id, "alk_tes");
assert_eq!(identity.scopes, vec!["admin"]);
}
#[test]
fn token_resolution_no_match_returns_none() {
let policy = AuthPolicy {
peers: vec![peer_entry("worker-a", &["ed25519:abc"])],
api_keys: vec![],
};
assert!(policy.resolve_identity_from_token("unknown").is_none());
}
#[test]
fn multi_fingerprint_peer_any_resolves_to_same_peer_id() {
let policy = AuthPolicy {
peers: vec![peer_entry("worker-a", &["ed25519:abc", "SHA256:def"])],
api_keys: vec![],
};
let id1 = policy
.resolve_identity_from_fingerprint("ed25519:abc")
.expect("first fingerprint resolves");
let id2 = policy
.resolve_identity_from_fingerprint("SHA256:def")
.expect("second fingerprint resolves");
assert_eq!(id1.id, "worker-a");
assert_eq!(id2.id, "worker-a");
}
#[test]
fn resources_populated_on_fingerprint_path() {
let mut resources = HashMap::new();
resources.insert("service".to_string(), vec!["gitea".to_string()]);
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
entry.resources = resources.clone();
let policy = AuthPolicy {
peers: vec![entry],
api_keys: vec![],
};
let identity = policy
.resolve_identity_from_fingerprint("ed25519:abc")
.expect("known fingerprint resolves");
assert_eq!(identity.resources, resources);
}
#[test]
fn resources_populated_on_token_path() {
let token = "bearer-secret";
let mut resources = HashMap::new();
resources.insert("service".to_string(), vec!["gitea".to_string()]);
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
entry.auth_token_hash = Some(sha256_hex(token));
entry.resources = resources.clone();
let policy = AuthPolicy {
peers: vec![entry],
api_keys: vec![],
};
let identity = policy
.resolve_identity_from_token(token)
.expect("matching token resolves");
assert_eq!(identity.resources, resources);
}
#[test]
fn duplicate_peer_id_validation_rejects() {
let policy = AuthPolicy {
peers: vec![
peer_entry("worker-a", &["ed25519:abc"]),
peer_entry("worker-a", &["ed25519:def"]),
],
api_keys: vec![],
};
let err = policy.validate_peer_ids().expect_err("duplicate detected");
assert_eq!(err.peer_id, "worker-a");
}
#[test]
fn unique_peer_ids_validate_ok() {
let policy = AuthPolicy {
peers: vec![
peer_entry("worker-a", &["ed25519:abc"]),
peer_entry("worker-b", &["ed25519:def"]),
],
api_keys: vec![],
};
assert!(policy.validate_peer_ids().is_ok());
}
// --- Ed25519SecretKey -------------------------------------------------
#[test]
fn ed25519_secret_key_round_trips_bytes() {
let key = Ed25519SecretKey::generate();
let bytes = key.as_bytes();
let restored = Ed25519SecretKey::from_bytes(&bytes);
assert_eq!(restored.as_bytes(), bytes);
}
#[test]
fn ed25519_secret_key_sign_verifies_against_public_key() {
use ed25519_dalek::{Signature, Verifier};
let key = Ed25519SecretKey::generate();
let public = key.public();
let message = b"alknet coverage check";
let signature: Signature = key.sign(message);
assert_eq!(signature.to_bytes().len(), 64);
assert!(
public.verify(message, &signature).is_ok(),
"signature produced by Ed25519SecretKey::sign must verify under its public key"
);
}
#[test]
fn ed25519_secret_key_sign_rejects_tampered_message() {
use ed25519_dalek::{Signature, Verifier};
let key = Ed25519SecretKey::generate();
let public = key.public();
let signature: Signature = key.sign(b"original message");
assert!(
public.verify(b"tampered message", &signature).is_err(),
"signature must not verify against a different message"
);
}
#[test]
fn ed25519_secret_key_debug_does_not_leak_material() {
let key = Ed25519SecretKey::generate();
let dbg = format!("{key:?}");
assert!(dbg.contains("Ed25519SecretKey"));
assert!(!dbg.contains("SigningKey"));
let raw = hex::encode(key.as_bytes());
assert!(
!dbg.contains(&raw),
"Debug output must not contain the raw key bytes"
);
}
#[test]
fn ed25519_secret_key_public_matches_underlying_signing_key() {
let key = Ed25519SecretKey::generate();
let public = key.public();
assert_eq!(public.to_bytes().len(), 32);
}
}

View File

@@ -1,99 +0,0 @@
//! Configuration service for runtime config reload.
//!
//! See [ADR-030](docs/architecture/decisions/030-dynamic-config.md).
use std::sync::Arc;
use arc_swap::ArcSwap;
use super::{DynamicConfig, ForwardingPolicy, RateLimitConfig};
pub struct ConfigServiceImpl {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigServiceImpl {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
Self { dynamic }
}
pub fn forwarding_policy(&self) -> Arc<ForwardingPolicy> {
Arc::new(self.dynamic.load().forwarding.clone())
}
pub fn rate_limits(&self) -> Arc<RateLimitConfig> {
Arc::new(self.dynamic.load().rate_limits.clone())
}
pub fn reload(&self, new_config: DynamicConfig) {
self.dynamic.store(Arc::new(new_config));
}
}
impl std::fmt::Debug for ConfigServiceImpl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigServiceImpl").finish()
}
}
#[cfg(feature = "irpc")]
#[allow(dead_code)]
pub enum ConfigProtocol {
GetForwardingPolicy,
GetRateLimits,
ReloadForwarding { policy: ForwardingPolicy },
ReloadRateLimits { limits: RateLimitConfig },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AuthPolicy;
#[test]
fn config_service_impl_forwarding_policy() {
let (arc_swap, _) = super::super::new_dynamic_config();
let service = ConfigServiceImpl::new(Arc::clone(&arc_swap));
let policy = service.forwarding_policy();
assert_eq!(policy.default, ForwardingPolicy::allow_all().default);
}
#[test]
fn config_service_impl_rate_limits() {
let (arc_swap, _) = super::super::new_dynamic_config();
let service = ConfigServiceImpl::new(Arc::clone(&arc_swap));
let limits = service.rate_limits();
assert_eq!(limits.max_auth_attempts, 10);
}
#[test]
fn config_service_impl_reload() {
let (arc_swap, _) = super::super::new_dynamic_config();
let service = ConfigServiceImpl::new(Arc::clone(&arc_swap));
assert_eq!(
service.forwarding_policy().default,
ForwardingPolicy::allow_all().default
);
let new_config = DynamicConfig {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::deny_all(),
rate_limits: RateLimitConfig::default(),
credentials: std::collections::HashMap::new(),
};
service.reload(new_config);
assert_eq!(
service.forwarding_policy().default,
ForwardingPolicy::deny_all().default
);
}
#[test]
fn config_service_impl_debug() {
let (arc_swap, _) = super::super::new_dynamic_config();
let service = ConfigServiceImpl::new(Arc::clone(&arc_swap));
let debug_str = format!("{:?}", service);
assert!(debug_str.contains("ConfigServiceImpl"));
}
}

View File

@@ -1,603 +0,0 @@
//! Runtime-reloadable dynamic configuration (auth policy, forwarding policy, rate limits).
//!
//! See [ADR-030](docs/architecture/decisions/030-dynamic-config.md).
use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use russh::keys::ssh_key::HashAlg;
use crate::auth::identity::Identity;
use crate::auth::ServerAuthConfig;
use crate::config::forwarding::ForwardingPolicy;
use crate::credentials::CredentialSet;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ApiKeyEntry {
pub prefix: String,
pub hash: String,
pub scopes: Vec<String>,
pub description: String,
pub expires_at: Option<u64>,
}
pub const API_KEY_PREFIX: &str = "alk_";
pub struct AuthPolicy {
pub authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
pub cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
pub api_keys: Vec<ApiKeyEntry>,
encoded_keys: std::collections::HashSet<Vec<u8>>,
fingerprint_to_key: HashMap<String, russh::keys::PublicKey>,
}
fn encode_key_data(key: &russh::keys::PublicKey) -> Vec<u8> {
use russh::keys::helpers::EncodedExt;
key.key_data().encoded().unwrap_or_default()
}
impl AuthPolicy {
pub fn new(
authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
) -> Self {
Self::with_api_keys(authorized_keys, cert_authorities, Vec::new())
}
pub fn with_api_keys(
authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
api_keys: Vec<ApiKeyEntry>,
) -> Self {
let encoded_keys = authorized_keys.iter().map(encode_key_data).collect();
let fingerprint_to_key = authorized_keys
.iter()
.map(|k| (format!("{}", k.fingerprint(HashAlg::Sha256)), k.clone()))
.collect();
Self {
authorized_keys,
cert_authorities,
api_keys,
encoded_keys,
fingerprint_to_key,
}
}
pub fn from_server_auth_config(config: ServerAuthConfig) -> Self {
Self::new(config.authorized_keys, config.cert_authorities)
}
pub fn empty() -> Self {
Self::new(std::collections::HashSet::new(), Vec::new())
}
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
if self.fingerprint_to_key.contains_key(fingerprint) {
Some(Identity {
id: fingerprint.to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
})
} else {
None
}
}
pub fn resolve_api_key(&self, token: &str) -> Option<Identity> {
if !token.starts_with(API_KEY_PREFIX) {
return None;
}
let prefix_part = &token[..token.len().min(8)];
let entry = self
.api_keys
.iter()
.find(|e| prefix_part.starts_with(&e.prefix))?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
let expected_hash = format!("sha256:{}", hex::encode(result));
if entry.hash != expected_hash {
return None;
}
if let Some(expires_at) = entry.expires_at {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now_secs >= expires_at {
return None;
}
}
Some(Identity {
id: entry.prefix.clone(),
scopes: entry.scopes.clone(),
resources: HashMap::new(),
})
}
pub fn authenticate_publickey(
&self,
key: &russh::keys::PublicKey,
) -> Result<(), crate::error::AuthError> {
let encoded = encode_key_data(key);
if self.encoded_keys.contains(&encoded) {
return Ok(());
}
Err(crate::error::AuthError::KeyRejected)
}
pub fn authenticate_certificate(
&self,
cert: &russh::keys::Certificate,
user: &str,
client_ip: Option<std::net::IpAddr>,
) -> Result<(), crate::error::AuthError> {
use std::time::SystemTime;
let matching_ca = self
.cert_authorities
.iter()
.find(|ca| cert.signature_key() == ca.public_key.key_data());
let ca_entry = match matching_ca {
Some(entry) => entry,
None => return Err(crate::error::AuthError::CertInvalid),
};
if cert.verify_signature().is_err() {
return Err(crate::error::AuthError::CertInvalid);
}
let now = SystemTime::now();
let now_secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now_secs < cert.valid_after() || now_secs >= cert.valid_before() {
return Err(crate::error::AuthError::CertExpired);
}
let principals = cert.valid_principals();
if !principals.is_empty() && !principals.iter().any(|p| p == user) {
return Err(crate::error::AuthError::CertPrincipalMismatch);
}
check_critical_options(cert, ca_entry, client_ip)?;
check_extensions(cert, ca_entry)?;
Ok(())
}
}
fn check_critical_options(
cert: &russh::keys::Certificate,
ca_entry: &crate::auth::keys::CertAuthorityEntry,
client_ip: Option<std::net::IpAddr>,
) -> Result<(), crate::error::AuthError> {
let ca_has_no_pty = ca_entry.options.iter().any(|o| o == "no-pty");
for (name, data) in cert.critical_options().iter() {
match name.as_str() {
"source-address" => {
if !check_source_address(data, client_ip) {
return Err(crate::error::AuthError::CertInvalid);
}
}
"force-command" => {}
"no-pty" => {}
_ => {
let _ = ca_has_no_pty;
return Err(crate::error::AuthError::CertInvalid);
}
}
}
Ok(())
}
fn check_extensions(
cert: &russh::keys::Certificate,
ca_entry: &crate::auth::keys::CertAuthorityEntry,
) -> Result<(), crate::error::AuthError> {
let ca_permit_port_forwarding = ca_entry
.options
.iter()
.any(|o| o == "permit-port-forwarding");
if ca_permit_port_forwarding {
let cert_allows = cert
.extensions()
.iter()
.any(|(n, _)| n == "permit-port-forwarding");
if !cert_allows {
return Err(crate::error::AuthError::CertInvalid);
}
}
Ok(())
}
fn check_source_address(allowed: &str, client_ip: Option<std::net::IpAddr>) -> bool {
use ipnetwork::IpNetwork;
use std::net::IpAddr;
use std::str::FromStr;
let Some(ip) = client_ip else {
return false;
};
for pattern in allowed.split(',') {
let pattern = pattern.trim();
if pattern.is_empty() {
continue;
}
if let Ok(cidr) = IpNetwork::from_str(pattern) {
if cidr.contains(ip) {
return true;
}
}
if let Ok(net_ip) = IpAddr::from_str(pattern) {
if net_ip == ip {
return true;
}
}
}
false
}
impl std::fmt::Debug for AuthPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthPolicy")
.field("authorized_keys_count", &self.authorized_keys.len())
.field("cert_authorities_count", &self.cert_authorities.len())
.field("api_keys_count", &self.api_keys.len())
.finish()
}
}
impl Clone for AuthPolicy {
fn clone(&self) -> Self {
Self {
authorized_keys: self.authorized_keys.clone(),
cert_authorities: self.cert_authorities.clone(),
api_keys: self.api_keys.clone(),
encoded_keys: self.encoded_keys.clone(),
fingerprint_to_key: self.fingerprint_to_key.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub max_connections_per_ip: usize,
pub max_auth_attempts: usize,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_connections_per_ip: 0,
max_auth_attempts: 10,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DynamicConfig {
pub auth: AuthPolicy,
pub forwarding: ForwardingPolicy,
pub rate_limits: RateLimitConfig,
pub credentials: HashMap<String, CredentialSet>,
}
impl DynamicConfig {
pub fn new(auth: AuthPolicy) -> Self {
Self {
auth,
forwarding: ForwardingPolicy::allow_all(),
rate_limits: RateLimitConfig::default(),
credentials: HashMap::new(),
}
}
pub fn from_parts(
auth: AuthPolicy,
forwarding: ForwardingPolicy,
rate_limits: RateLimitConfig,
) -> Self {
Self {
auth,
forwarding,
rate_limits,
credentials: HashMap::new(),
}
}
pub fn with_forwarding_policy(mut self, policy: ForwardingPolicy) -> Self {
self.forwarding = policy;
self
}
pub fn with_rate_limits(mut self, limits: RateLimitConfig) -> Self {
self.rate_limits = limits;
self
}
pub fn with_credentials(mut self, credentials: HashMap<String, CredentialSet>) -> Self {
self.credentials = credentials;
self
}
}
impl Default for DynamicConfig {
fn default() -> Self {
Self {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::allow_all(),
rate_limits: RateLimitConfig::default(),
credentials: HashMap::new(),
}
}
}
pub struct ConfigReloadHandle {
pub(crate) dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigReloadHandle {
pub fn reload(&self, new_config: DynamicConfig) {
self.dynamic.store(Arc::new(new_config));
}
pub fn dynamic(&self) -> Arc<DynamicConfig> {
self.dynamic.load_full()
}
pub fn dynamic_arc(&self) -> Arc<ArcSwap<DynamicConfig>> {
Arc::clone(&self.dynamic)
}
}
impl std::fmt::Debug for ConfigReloadHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigReloadHandle").finish()
}
}
pub fn new_dynamic_config() -> (Arc<ArcSwap<DynamicConfig>>, ConfigReloadHandle) {
let inner = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let handle = ConfigReloadHandle {
dynamic: Arc::clone(&inner),
};
(inner, handle)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::forwarding::ForwardingAction;
#[test]
fn forwarding_policy_allow_all_default() {
let policy = ForwardingPolicy::allow_all();
assert_eq!(policy.default, ForwardingAction::Allow);
assert!(policy.rules.is_empty());
}
#[test]
fn forwarding_policy_deny_all() {
let policy = ForwardingPolicy::deny_all();
assert_eq!(policy.default, ForwardingAction::Deny);
assert!(policy.rules.is_empty());
}
#[test]
fn dynamic_config_default() {
let config = DynamicConfig::default();
assert_eq!(config.forwarding.default, ForwardingAction::Allow);
assert_eq!(config.rate_limits.max_connections_per_ip, 0);
assert_eq!(config.rate_limits.max_auth_attempts, 10);
}
#[test]
fn config_reload_handle_updates_dynamic() {
let (arc_swap, handle) = new_dynamic_config();
let initial = arc_swap.load();
assert_eq!(initial.forwarding.default, ForwardingAction::Allow);
let new_config = DynamicConfig {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::deny_all(),
rate_limits: RateLimitConfig::default(),
credentials: HashMap::new(),
};
handle.reload(new_config);
let updated = arc_swap.load();
assert_eq!(updated.forwarding.default, ForwardingAction::Deny);
}
#[test]
fn dynamic_config_with_forwarding_policy_builder() {
let config = DynamicConfig::new(AuthPolicy::empty())
.with_forwarding_policy(ForwardingPolicy::deny_all());
assert_eq!(config.forwarding.default, ForwardingAction::Deny);
}
#[test]
fn rate_limit_config_custom() {
let limits = RateLimitConfig {
max_connections_per_ip: 5,
max_auth_attempts: 3,
};
assert_eq!(limits.max_connections_per_ip, 5);
assert_eq!(limits.max_auth_attempts, 3);
}
#[test]
fn forwarding_action_equality() {
assert_eq!(ForwardingAction::Allow, ForwardingAction::Allow);
assert_eq!(ForwardingAction::Deny, ForwardingAction::Deny);
assert_ne!(ForwardingAction::Allow, ForwardingAction::Deny);
}
#[test]
fn auth_policy_empty_rejects_all() {
let policy = AuthPolicy::empty();
let key_text = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE other@host";
let other_ssh_key =
russh::keys::parse_public_key_base64(key_text.split_whitespace().nth(1).unwrap())
.unwrap();
assert_eq!(
policy.authenticate_publickey(&other_ssh_key),
Err(crate::error::AuthError::KeyRejected)
);
}
#[test]
fn auth_policy_debug_redacts_keys() {
let policy = AuthPolicy::empty();
let debug_str = format!("{:?}", policy);
assert!(debug_str.contains("authorized_keys_count"));
assert!(debug_str.contains("cert_authorities_count"));
assert!(debug_str.contains("api_keys_count"));
}
fn compute_api_key_hash(token: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
format!("sha256:{}", hex::encode(result))
}
#[test]
fn api_key_valid_authenticates() {
let token = "alk_testsecret123";
let hash = compute_api_key_hash(token);
let entry = ApiKeyEntry {
prefix: "alk_test".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "test key".to_string(),
expires_at: None,
};
let policy =
AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]);
let identity = policy.resolve_api_key(token);
assert!(identity.is_some());
let identity = identity.unwrap();
assert_eq!(identity.id, "alk_test");
assert_eq!(identity.scopes, vec!["relay:connect"]);
}
#[test]
fn api_key_expired_rejected() {
let token = "alk_expiredkey1";
let hash = compute_api_key_hash(token);
let entry = ApiKeyEntry {
prefix: "alk_expi".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "expired key".to_string(),
expires_at: Some(1),
};
let policy =
AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]);
assert!(policy.resolve_api_key(token).is_none());
}
#[test]
fn api_key_wrong_hash_rejected() {
let entry = ApiKeyEntry {
prefix: "alk_test".to_string(),
hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
scopes: vec!["relay:connect".to_string()],
description: "bad hash".to_string(),
expires_at: None,
};
let policy =
AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]);
assert!(policy.resolve_api_key("alk_testsecret123").is_none());
}
#[test]
fn api_key_unknown_prefix_falls_through() {
let token = "alk_testsecret123";
let hash = compute_api_key_hash(token);
let entry = ApiKeyEntry {
prefix: "alk_other".to_string(),
hash,
scopes: vec!["relay:connect".to_string()],
description: "other key".to_string(),
expires_at: None,
};
let policy =
AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]);
assert!(policy.resolve_api_key(token).is_none());
}
#[test]
fn api_key_scopes_propagate() {
let token = "alk_scopesecret";
let hash = compute_api_key_hash(token);
let entry = ApiKeyEntry {
prefix: "alk_sco".to_string(),
hash,
scopes: vec!["relay:connect".to_string(), "secrets:derive".to_string()],
description: "scoped key".to_string(),
expires_at: None,
};
let policy =
AuthPolicy::with_api_keys(std::collections::HashSet::new(), Vec::new(), vec![entry]);
let identity = policy.resolve_api_key(token).unwrap();
assert_eq!(identity.scopes, vec!["relay:connect", "secrets:derive"]);
}
#[test]
fn non_api_key_prefix_returns_none() {
let policy = AuthPolicy::empty();
assert!(policy.resolve_api_key("bearer-some-token").is_none());
assert!(policy.resolve_api_key("regular-token").is_none());
}
#[test]
fn api_key_entry_default_empty() {
let config = DynamicConfig::default();
assert!(config.auth.api_keys.is_empty());
}
#[test]
fn auth_policy_with_api_keys_preserves_entries() {
let entry = ApiKeyEntry {
prefix: "alk_abc".to_string(),
hash: "sha256:abcdef".to_string(),
scopes: vec!["relay:connect".to_string()],
description: "test".to_string(),
expires_at: None,
};
let policy = AuthPolicy::with_api_keys(
std::collections::HashSet::new(),
Vec::new(),
vec![entry.clone()],
);
assert_eq!(policy.api_keys.len(), 1);
assert_eq!(policy.api_keys[0], entry);
}
}

View File

@@ -1,534 +0,0 @@
//! Forwarding policy engine for per-identity and per-transport access control.
//!
//! See [ADR-031](docs/architecture/decisions/031-forwarding-policy.md).
use std::net::IpAddr;
use std::ops::Range;
use std::str::FromStr;
use ipnetwork::IpNetwork;
use crate::auth::identity::Identity;
use crate::transport::TransportKind;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ForwardingAction {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum TargetPattern {
Any,
Host(String),
Cidr(IpNetwork),
PortRange(String, Range<u16>),
AlknetPrefix,
}
impl TargetPattern {
pub fn matches(&self, target: &str, port: u16) -> bool {
match self {
TargetPattern::Any => true,
TargetPattern::Host(pattern) => match_host_pattern(pattern, target),
TargetPattern::Cidr(network) => match_cidr(network, target),
TargetPattern::PortRange(host_pattern, port_range) => {
match_host_pattern(host_pattern, target) && port_range.contains(&port)
}
TargetPattern::AlknetPrefix => {
target.starts_with(crate::server::control_channel::ALKNET_PREFIX)
}
}
}
}
fn match_host_pattern(pattern: &str, target: &str) -> bool {
if pattern == target {
return true;
}
if pattern.contains('*') {
if let Some(pos) = pattern.find('*') {
let prefix = &pattern[..pos];
let suffix = &pattern[pos + 1..];
return target.starts_with(prefix)
&& target.ends_with(suffix)
&& target.len() >= prefix.len() + suffix.len();
}
}
false
}
fn match_cidr(network: &IpNetwork, target: &str) -> bool {
let Ok(addr) = IpAddr::from_str(target) else {
return false;
};
network.contains(addr)
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ForwardingRule {
pub target: TargetPattern,
pub action: ForwardingAction,
pub principals: Vec<String>,
pub transports: Vec<TransportKind>,
}
impl ForwardingRule {
pub fn new(
target: TargetPattern,
action: ForwardingAction,
principals: Vec<String>,
transports: Vec<TransportKind>,
) -> Self {
Self {
target,
action,
principals,
transports,
}
}
}
impl ForwardingRule {
fn matches_principal(&self, identity: &Identity) -> bool {
if self.principals.is_empty() {
return true;
}
self.principals
.iter()
.any(|p| p == &identity.id || identity.scopes.contains(p))
}
fn matches_transport(&self, transport: &TransportKind) -> bool {
if self.transports.is_empty() {
return true;
}
self.transports.contains(transport)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ForwardingPolicy {
pub default: ForwardingAction,
pub rules: Vec<ForwardingRule>,
}
impl ForwardingPolicy {
pub fn allow_all() -> Self {
Self {
default: ForwardingAction::Allow,
rules: Vec::new(),
}
}
pub fn deny_all() -> Self {
Self {
default: ForwardingAction::Deny,
rules: Vec::new(),
}
}
pub fn check(
&self,
target: &str,
port: u16,
identity: &Identity,
transport: TransportKind,
) -> bool {
for rule in &self.rules {
if rule.target.matches(target, port)
&& rule.matches_principal(identity)
&& rule.matches_transport(&transport)
{
return rule.action == ForwardingAction::Allow;
}
}
self.default == ForwardingAction::Allow
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_identity(id: &str, scopes: Vec<&str>) -> Identity {
Identity {
id: id.to_string(),
scopes: scopes.into_iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
#[test]
fn forwarding_action_equality() {
assert_eq!(ForwardingAction::Allow, ForwardingAction::Allow);
assert_eq!(ForwardingAction::Deny, ForwardingAction::Deny);
assert_ne!(ForwardingAction::Allow, ForwardingAction::Deny);
}
#[test]
fn allow_all_allows_everything() {
let policy = ForwardingPolicy::allow_all();
let identity = make_identity("user1", vec![]);
assert!(policy.check("example.com", 80, &identity, TransportKind::Tcp));
assert!(policy.check(
"10.0.0.1",
22,
&identity,
TransportKind::Tls { server_name: None }
));
}
#[test]
fn deny_all_denies_everything() {
let policy = ForwardingPolicy::deny_all();
let identity = make_identity("user1", vec![]);
assert!(!policy.check("example.com", 80, &identity, TransportKind::Tcp));
assert!(!policy.check(
"10.0.0.1",
22,
&identity,
TransportKind::Tls { server_name: None }
));
}
#[test]
fn first_match_wins_allowlist() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Host("allowed.example.com".to_string()),
action: ForwardingAction::Allow,
principals: vec![],
transports: vec![],
}],
};
let identity = make_identity("user1", vec![]);
assert!(policy.check("allowed.example.com", 80, &identity, TransportKind::Tcp));
assert!(!policy.check("denied.example.com", 80, &identity, TransportKind::Tcp));
}
#[test]
fn first_match_wins_blocklist() {
let policy = ForwardingPolicy {
default: ForwardingAction::Allow,
rules: vec![ForwardingRule {
target: TargetPattern::Host("blocked.example.com".to_string()),
action: ForwardingAction::Deny,
principals: vec![],
transports: vec![],
}],
};
let identity = make_identity("user1", vec![]);
assert!(!policy.check("blocked.example.com", 80, &identity, TransportKind::Tcp));
assert!(policy.check("allowed.example.com", 80, &identity, TransportKind::Tcp));
}
#[test]
fn first_match_wins_ordering() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![
ForwardingRule {
target: TargetPattern::Any,
action: ForwardingAction::Allow,
principals: vec![],
transports: vec![],
},
ForwardingRule {
target: TargetPattern::Host("blocked.example.com".to_string()),
action: ForwardingAction::Deny,
principals: vec![],
transports: vec![],
},
],
};
let identity = make_identity("user1", vec![]);
assert!(policy.check("blocked.example.com", 80, &identity, TransportKind::Tcp));
}
#[test]
fn empty_principals_matches_all() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Any,
action: ForwardingAction::Allow,
principals: vec![],
transports: vec![],
}],
};
let identity1 = make_identity("user1", vec![]);
let identity2 = make_identity("user2", vec![]);
assert!(policy.check("example.com", 80, &identity1, TransportKind::Tcp));
assert!(policy.check("example.com", 80, &identity2, TransportKind::Tcp));
}
#[test]
fn principal_matching_by_id() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Any,
action: ForwardingAction::Allow,
principals: vec!["SHA256:abc123".to_string()],
transports: vec![],
}],
};
let allowed = make_identity("SHA256:abc123", vec![]);
let denied = make_identity("SHA256:other", vec![]);
assert!(policy.check("example.com", 80, &allowed, TransportKind::Tcp));
assert!(!policy.check("example.com", 80, &denied, TransportKind::Tcp));
}
#[test]
fn principal_matching_by_scope() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Any,
action: ForwardingAction::Allow,
principals: vec!["admin".to_string()],
transports: vec![],
}],
};
let allowed = make_identity("user1", vec!["admin"]);
let denied = make_identity("user2", vec!["viewer"]);
assert!(policy.check("example.com", 80, &allowed, TransportKind::Tcp));
assert!(!policy.check("example.com", 80, &denied, TransportKind::Tcp));
}
#[test]
fn empty_transports_matches_all() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Any,
action: ForwardingAction::Allow,
principals: vec![],
transports: vec![],
}],
};
let identity = make_identity("user1", vec![]);
assert!(policy.check("example.com", 80, &identity, TransportKind::Tcp));
assert!(policy.check(
"example.com",
80,
&identity,
TransportKind::Tls { server_name: None }
));
assert!(policy.check(
"example.com",
80,
&identity,
TransportKind::Iroh {
endpoint_id: String::new()
}
));
}
#[test]
fn transport_matching() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Any,
action: ForwardingAction::Allow,
principals: vec![],
transports: vec![TransportKind::Tls { server_name: None }],
}],
};
let identity = make_identity("user1", vec![]);
assert!(!policy.check("example.com", 443, &identity, TransportKind::Tcp));
assert!(policy.check(
"example.com",
443,
&identity,
TransportKind::Tls { server_name: None }
));
}
#[test]
fn target_pattern_any_matches_all() {
let pattern = TargetPattern::Any;
assert!(pattern.matches("example.com", 80));
assert!(pattern.matches("10.0.0.1", 22));
assert!(pattern.matches("alknet-control", 0));
}
#[test]
fn target_pattern_host_exact_match() {
let pattern = TargetPattern::Host("example.com".to_string());
assert!(pattern.matches("example.com", 80));
assert!(!pattern.matches("other.com", 80));
assert!(!pattern.matches("sub.example.com", 80));
}
#[test]
fn target_pattern_host_glob_match() {
let pattern = TargetPattern::Host("*.example.com".to_string());
assert!(pattern.matches("sub.example.com", 80));
assert!(pattern.matches("a.example.com", 443));
assert!(!pattern.matches("example.com", 80));
assert!(!pattern.matches("xsub.example.com.org", 80));
}
#[test]
fn target_pattern_host_glob_prefix() {
let pattern = TargetPattern::Host("db-*".to_string());
assert!(pattern.matches("db-primary", 5432));
assert!(pattern.matches("db-replica", 5432));
assert!(!pattern.matches("web-primary", 5432));
}
#[test]
fn target_pattern_host_glob_suffix() {
let pattern = TargetPattern::Host("*.internal".to_string());
assert!(pattern.matches("app.internal", 8080));
assert!(pattern.matches("db.internal", 5432));
assert!(!pattern.matches("app.external", 80));
}
#[test]
fn target_pattern_cidr_matches_ip() {
let network: IpNetwork = "10.0.0.0/8".parse().unwrap();
let pattern = TargetPattern::Cidr(network);
assert!(pattern.matches("10.0.0.1", 22));
assert!(pattern.matches("10.255.255.255", 22));
assert!(!pattern.matches("192.168.1.1", 22));
assert!(!pattern.matches("not-an-ip", 22));
}
#[test]
fn target_pattern_cidr_ipv6() {
let network: IpNetwork = "fd00::/8".parse().unwrap();
let pattern = TargetPattern::Cidr(network);
assert!(pattern.matches("fd00::1", 22));
assert!(!pattern.matches("10.0.0.1", 22));
}
#[test]
fn target_pattern_port_range_matches() {
let pattern = TargetPattern::PortRange("localhost".to_string(), 8080..8090);
assert!(pattern.matches("localhost", 8080));
assert!(pattern.matches("localhost", 8085));
assert!(pattern.matches("localhost", 8089));
assert!(!pattern.matches("localhost", 8079));
assert!(!pattern.matches("localhost", 8090));
assert!(!pattern.matches("otherhost", 8080));
}
#[test]
fn target_pattern_port_range_with_glob() {
let pattern = TargetPattern::PortRange("*.internal".to_string(), 3000..4000);
assert!(pattern.matches("app.internal", 3000));
assert!(pattern.matches("app.internal", 3999));
assert!(!pattern.matches("app.internal", 2999));
assert!(!pattern.matches("app.internal", 4000));
assert!(!pattern.matches("app.external", 3000));
}
#[test]
fn target_pattern_alknet_prefix() {
let pattern = TargetPattern::AlknetPrefix;
assert!(pattern.matches("alknet-control", 0));
assert!(pattern.matches("alknet-status", 0));
assert!(pattern.matches("alknet-", 0));
assert!(!pattern.matches("example.com", 0));
assert!(!pattern.matches("alknet.example.com", 0));
}
#[test]
fn default_fallthrough_allow() {
let policy = ForwardingPolicy {
default: ForwardingAction::Allow,
rules: vec![],
};
let identity = make_identity("user1", vec![]);
assert!(policy.check("anything", 80, &identity, TransportKind::Tcp));
}
#[test]
fn default_fallthrough_deny() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![],
};
let identity = make_identity("user1", vec![]);
assert!(!policy.check("anything", 80, &identity, TransportKind::Tcp));
}
#[test]
fn combined_principal_and_transport_matching() {
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Host("restricted.example.com".to_string()),
action: ForwardingAction::Allow,
principals: vec!["admin".to_string()],
transports: vec![TransportKind::Tls { server_name: None }],
}],
};
let admin = make_identity("admin-user", vec!["admin"]);
let viewer = make_identity("viewer-user", vec!["viewer"]);
assert!(policy.check(
"restricted.example.com",
443,
&admin,
TransportKind::Tls { server_name: None }
));
assert!(!policy.check("restricted.example.com", 443, &admin, TransportKind::Tcp));
assert!(!policy.check(
"restricted.example.com",
443,
&viewer,
TransportKind::Tls { server_name: None }
));
}
#[test]
fn webtransport_restricted_to_alknet() {
let policy = ForwardingPolicy {
default: ForwardingAction::Allow,
rules: vec![
ForwardingRule {
target: TargetPattern::AlknetPrefix,
action: ForwardingAction::Allow,
principals: vec![],
transports: vec![TransportKind::WebTransport { server_name: None }],
},
ForwardingRule {
target: TargetPattern::Any,
action: ForwardingAction::Deny,
principals: vec![],
transports: vec![TransportKind::WebTransport { server_name: None }],
},
],
};
let identity = make_identity("user1", vec![]);
assert!(policy.check(
"alknet-control",
0,
&identity,
TransportKind::WebTransport { server_name: None }
));
assert!(!policy.check(
"example.com",
443,
&identity,
TransportKind::WebTransport { server_name: None }
));
assert!(policy.check("example.com", 443, &identity, TransportKind::Tcp));
}
#[test]
fn cidr_does_not_match_hostname() {
let network: IpNetwork = "10.0.0.0/8".parse().unwrap();
let pattern = TargetPattern::Cidr(network);
assert!(!pattern.matches("example.com", 22));
}
}

View File

@@ -1,12 +0,0 @@
pub mod config_service;
pub mod dynamic_config;
pub mod forwarding;
pub mod static_config;
pub use config_service::ConfigServiceImpl;
pub use dynamic_config::{
new_dynamic_config, ApiKeyEntry, AuthPolicy, ConfigReloadHandle, DynamicConfig,
RateLimitConfig, API_KEY_PREFIX,
};
pub use forwarding::{ForwardingAction, ForwardingPolicy, ForwardingRule, TargetPattern};
pub use static_config::StaticConfig;

View File

@@ -1,281 +0,0 @@
//! Static (immutable) server configuration resolved at startup.
//!
//! See [ADR-030](docs/architecture/decisions/030-dynamic-config.md).
use crate::interface::StreamInterfaceKind;
use crate::server::handler::{ProxyConfig, ProxyMode};
use crate::server::serve::{ListenerConfig, ServeTransportMode, StreamListenerConfig};
use crate::transport::TransportKind;
use std::net::SocketAddr;
pub struct StaticConfig {
pub transport_mode: ServeTransportMode,
pub listen_addr: String,
pub tls_cert: Option<String>,
pub tls_key: Option<String>,
pub acme_domain: Option<String>,
pub stealth: bool,
pub host_key: russh::keys::PrivateKey,
pub host_key_algorithm: russh::keys::Algorithm,
pub max_auth_attempts: usize,
pub max_connections_per_ip: usize,
pub proxy_config: Option<ProxyConfig>,
pub iroh_relay: Option<String>,
pub listeners: Vec<ListenerConfig>,
}
impl std::fmt::Debug for StaticConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StaticConfig")
.field("transport_mode", &self.transport_mode)
.field("listen_addr", &self.listen_addr)
.field("tls_cert", &self.tls_cert.as_ref().map(|_| "<redacted>"))
.field("tls_key", &self.tls_key.as_ref().map(|_| "<redacted>"))
.field("acme_domain", &self.acme_domain)
.field("stealth", &self.stealth)
.field("host_key_algorithm", &self.host_key_algorithm)
.field("max_auth_attempts", &self.max_auth_attempts)
.field("max_connections_per_ip", &self.max_connections_per_ip)
.field("proxy_config", &self.proxy_config)
.field("iroh_relay", &self.iroh_relay)
.field("listeners", &self.listeners)
.finish()
}
}
impl StaticConfig {
pub fn from_serve_options(
opts: crate::server::serve::ServeOptions,
) -> Result<(Self, crate::config::DynamicConfig), crate::error::ConfigError> {
opts.validate()?;
let host_key = crate::auth::keys::load_private_key(opts.key.clone())?;
let host_key_algorithm = host_key.algorithm();
let auth_config = crate::auth::ServerAuthConfig::from_keys_and_ca(
opts.authorized_keys.clone(),
opts.cert_authority.clone(),
)?;
let auth_policy = crate::config::AuthPolicy::from_server_auth_config(auth_config);
let dynamic = crate::config::DynamicConfig::new(auth_policy);
let proxy_config = parse_proxy_config(opts.proxy.as_deref())?;
let listeners = if let Some(listeners) = opts.listeners {
listeners
} else {
vec![ListenerConfig::Stream {
config: StreamListenerConfig {
transport_kind: match opts.transport_mode {
ServeTransportMode::Tcp => TransportKind::Tcp,
ServeTransportMode::Tls => TransportKind::Tls { server_name: None },
ServeTransportMode::Iroh => TransportKind::Iroh {
endpoint_id: String::new(),
},
},
interface: StreamInterfaceKind::Ssh,
listen_addr: opts.listen_addr.clone(),
tls_cert: opts.tls_cert.clone(),
tls_key: opts.tls_key.clone(),
acme_domain: opts.acme_domain.clone(),
stealth: opts.stealth,
iroh_relay: opts.iroh_relay.clone(),
},
}]
};
let static_config = StaticConfig {
transport_mode: opts.transport_mode,
listen_addr: opts.listen_addr,
tls_cert: opts.tls_cert,
tls_key: opts.tls_key,
acme_domain: opts.acme_domain,
stealth: opts.stealth,
host_key,
host_key_algorithm,
max_auth_attempts: opts.max_auth_attempts,
max_connections_per_ip: opts.max_connections_per_ip,
proxy_config,
iroh_relay: opts.iroh_relay,
listeners,
};
Ok((static_config, dynamic))
}
}
fn parse_proxy_config(
proxy: Option<&str>,
) -> Result<Option<ProxyConfig>, crate::error::ConfigError> {
match proxy {
None => Ok(None),
Some(url) => {
if let Some(rest) = url.strip_prefix("socks5://") {
let addr: SocketAddr =
rest.parse()
.map_err(|e| crate::error::ConfigError::ProxyConfigInvalid {
message: format!("invalid socks5 proxy address '{}': {}", rest, e),
})?;
Ok(Some(ProxyConfig {
mode: ProxyMode::Socks5(addr),
}))
} else if let Some(rest) = url.strip_prefix("http://") {
let addr: SocketAddr =
rest.parse()
.map_err(|e| crate::error::ConfigError::ProxyConfigInvalid {
message: format!(
"invalid http connect proxy address '{}': {}",
rest, e
),
})?;
Ok(Some(ProxyConfig {
mode: ProxyMode::HttpConnect(addr),
}))
} else {
Err(crate::error::ConfigError::ProxyConfigInvalid {
message: format!("unsupported proxy URL scheme: {}", url),
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::keys::KeySource;
use crate::server::serve::ServeOptions;
use crate::transport::TransportKind;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096";
fn make_key_source() -> KeySource {
KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec())
}
fn make_authorized_keys_source() -> KeySource {
KeySource::Memory(ED25519_PUBLIC_KEY.as_bytes().to_vec())
}
#[test]
fn parse_proxy_config_socks5() {
let config = parse_proxy_config(Some("socks5://127.0.0.1:9050")).unwrap();
assert!(config.is_some());
match config.unwrap().mode {
ProxyMode::Socks5(addr) => {
assert_eq!(addr, "127.0.0.1:9050".parse().unwrap());
}
_ => panic!("expected Socks5"),
}
}
#[test]
fn parse_proxy_config_http() {
let config = parse_proxy_config(Some("http://127.0.0.1:8080")).unwrap();
assert!(config.is_some());
match config.unwrap().mode {
ProxyMode::HttpConnect(addr) => {
assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
}
_ => panic!("expected HttpConnect"),
}
}
#[test]
fn parse_proxy_config_none() {
assert!(parse_proxy_config(None).unwrap().is_none());
}
#[test]
fn parse_proxy_config_invalid_scheme() {
let result = parse_proxy_config(Some("ftp://127.0.0.1:9050"));
assert!(result.is_err());
match result.unwrap_err() {
crate::error::ConfigError::ProxyConfigInvalid { message } => {
assert!(message.contains("unsupported proxy URL scheme"));
}
e => panic!("expected ProxyConfigInvalid, got {:?}", e),
}
}
#[test]
fn parse_proxy_config_invalid_address() {
let result = parse_proxy_config(Some("socks5://not-an-address"));
assert!(result.is_err());
match result.unwrap_err() {
crate::error::ConfigError::ProxyConfigInvalid { message } => {
assert!(message.contains("invalid socks5 proxy address"));
}
e => panic!("expected ProxyConfigInvalid, got {:?}", e),
}
}
#[test]
fn static_config_from_serve_options_basic() {
let opts =
ServeOptions::new(make_key_source()).authorized_keys(make_authorized_keys_source());
let (static_config, dynamic) = StaticConfig::from_serve_options(opts).unwrap();
assert_eq!(static_config.listen_addr, "0.0.0.0:22");
assert_eq!(static_config.max_auth_attempts, 10);
assert!(dynamic.auth.authorized_keys.len() > 0);
}
#[test]
fn static_config_from_serve_options_with_proxy() {
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source())
.proxy("socks5://127.0.0.1:9050");
let (static_config, _) = StaticConfig::from_serve_options(opts).unwrap();
assert!(static_config.proxy_config.is_some());
}
#[test]
fn static_config_from_serve_options_with_listeners() {
let listeners = vec![ListenerConfig::tcp("0.0.0.0:22")];
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source())
.listeners(listeners);
let (static_config, _) = StaticConfig::from_serve_options(opts).unwrap();
assert_eq!(static_config.listeners.len(), 1);
match &static_config.listeners[0] {
ListenerConfig::Stream { config } => {
assert_eq!(config.transport_kind, TransportKind::Tcp);
}
_ => panic!("expected Stream variant"),
}
}
#[test]
fn static_config_from_serve_options_invalid_proxy_returns_err() {
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source())
.proxy("ftp://bad-scheme");
let result = StaticConfig::from_serve_options(opts);
assert!(result.is_err());
match result.unwrap_err() {
crate::error::ConfigError::ProxyConfigInvalid { message } => {
assert!(message.contains("unsupported proxy URL scheme"));
}
e => panic!("expected ProxyConfigInvalid, got {:?}", e),
}
}
#[test]
fn static_config_from_serve_options_malformed_proxy_address_returns_err() {
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source())
.proxy("socks5://not-a-valid-addr");
let result = StaticConfig::from_serve_options(opts);
assert!(result.is_err());
match result.unwrap_err() {
crate::error::ConfigError::ProxyConfigInvalid { message } => {
assert!(message.contains("invalid socks5 proxy address"));
}
e => panic!("expected ProxyConfigInvalid, got {:?}", e),
}
}
}

View File

@@ -1,241 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use serde::{Deserialize, Serialize};
use crate::config::DynamicConfig;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
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>,
},
}
pub trait CredentialProvider: Send + Sync + 'static {
fn get_credentials(&self, service: &str) -> Option<CredentialSet>;
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet>;
}
pub struct ConfigCredentialProvider {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigCredentialProvider {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
Self { dynamic }
}
}
impl CredentialProvider for ConfigCredentialProvider {
fn get_credentials(&self, service: &str) -> Option<CredentialSet> {
let config = self.dynamic.load();
config.credentials.get(service).cloned()
}
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet> {
self.get_credentials(service)
}
}
pub struct SecretStoreCredentialProvider;
impl SecretStoreCredentialProvider {
pub fn new() -> Self {
Self
}
}
impl Default for SecretStoreCredentialProvider {
fn default() -> Self {
Self::new()
}
}
impl CredentialProvider for SecretStoreCredentialProvider {
fn get_credentials(&self, _service: &str) -> Option<CredentialSet> {
None
}
fn refresh_credentials(&self, _service: &str) -> Option<CredentialSet> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AuthPolicy;
fn make_dynamic_with_credentials() -> Arc<ArcSwap<DynamicConfig>> {
let mut credentials = HashMap::new();
credentials.insert(
"vast-ai".to_string(),
CredentialSet::Bearer {
token: "secret-token".to_string(),
},
);
credentials.insert(
"custom-service".to_string(),
CredentialSet::ApiKey {
header_name: "X-API-Key".to_string(),
token: "api-key-123".to_string(),
},
);
let config = DynamicConfig::new(AuthPolicy::empty()).with_credentials(credentials);
Arc::new(ArcSwap::new(Arc::new(config)))
}
fn make_dynamic_empty() -> Arc<ArcSwap<DynamicConfig>> {
let config = DynamicConfig::default();
Arc::new(ArcSwap::new(Arc::new(config)))
}
#[test]
fn config_credential_provider_returns_configured_credentials() {
let dynamic = make_dynamic_with_credentials();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("vast-ai");
assert!(creds.is_some());
match creds.unwrap() {
CredentialSet::Bearer { token } => assert_eq!(token, "secret-token"),
_ => panic!("expected Bearer variant"),
}
}
#[test]
fn config_credential_provider_returns_api_key_variant() {
let dynamic = make_dynamic_with_credentials();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("custom-service");
assert!(creds.is_some());
match creds.unwrap() {
CredentialSet::ApiKey { header_name, token } => {
assert_eq!(header_name, "X-API-Key");
assert_eq!(token, "api-key-123");
}
_ => panic!("expected ApiKey variant"),
}
}
#[test]
fn config_credential_provider_returns_none_for_unknown_service() {
let dynamic = make_dynamic_with_credentials();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("nonexistent");
assert!(creds.is_none());
}
#[test]
fn config_credential_provider_empty_config_returns_none() {
let dynamic = make_dynamic_empty();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("vast-ai");
assert!(creds.is_none());
}
#[test]
fn secret_store_credential_provider_returns_none() {
let provider = SecretStoreCredentialProvider::new();
assert!(provider.get_credentials("vast-ai").is_none());
assert!(provider.get_credentials("rustfs").is_none());
assert!(provider.get_credentials("gitea").is_none());
}
#[test]
fn secret_store_credential_provider_refresh_returns_none() {
let provider = SecretStoreCredentialProvider::new();
assert!(provider.refresh_credentials("vast-ai").is_none());
}
#[test]
fn credential_set_bearer_serialization() {
let creds = CredentialSet::Bearer {
token: "tok".to_string(),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_s3_access_key_serialization() {
let creds = CredentialSet::S3AccessKey {
access_key: "AKIA123".to_string(),
secret_key: "secret".to_string(),
session_token: Some("session".to_string()),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_oidc_token_serialization() {
let creds = CredentialSet::OidcToken {
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at: Some(1234567890),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_custom_serialization() {
let mut params = HashMap::new();
params.insert("key1".to_string(), "val1".to_string());
let creds = CredentialSet::Custom {
scheme: "X-Custom".to_string(),
params,
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_basic_serialization() {
let creds = CredentialSet::Basic {
username: "user".to_string(),
password: "pass".to_string(),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_clone() {
let creds = CredentialSet::Bearer {
token: "tok".to_string(),
};
let cloned = creds.clone();
assert_eq!(creds, cloned);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,241 +0,0 @@
//! Error types for alknet-core.
//!
//! Layered error hierarchy:
//! - `TransportError` — connection/handshake/timeout errors (trigger reconnection on client)
//! - `AuthError` — key rejection, certificate validation failures
//! - `ChannelError` — per-channel failures (target unreachable, channel closed)
//! - `ConfigError` — invalid configuration (flags, key files, bind failures)
//! - `ForwardError` — port forward setup and connection failures
use std::io;
#[derive(Debug, thiserror::Error)]
pub enum TransportError {
#[error("connection failed")]
ConnectionFailed,
#[error("handshake failed")]
HandshakeFailed {
#[source]
source: io::Error,
},
#[error("transport timeout")]
Timeout,
#[error("proxy failed")]
ProxyFailed {
#[source]
source: io::Error,
},
}
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum AuthError {
#[error("key rejected")]
KeyRejected,
#[error("certificate invalid")]
CertInvalid,
#[error("certificate expired")]
CertExpired,
#[error("certificate principal mismatch")]
CertPrincipalMismatch,
#[error("no matching key")]
NoMatchingKey,
}
#[derive(Debug, thiserror::Error)]
pub enum ChannelError {
#[error("target unreachable")]
TargetUnreachable,
#[error("proxy connect failed")]
ProxyConnectFailed {
#[source]
source: io::Error,
},
#[error("channel closed")]
ChannelClosed,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("invalid flag: {name}")]
InvalidFlag { name: String },
#[error("key file not found: {path}")]
KeyFileNotFound { path: String },
#[error("bind failed")]
BindFailed {
#[source]
source: io::Error,
},
#[error("incompatible options")]
IncompatibleOptions,
#[error("invalid proxy config: {message}")]
ProxyConfigInvalid { message: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ForwardError {
#[error("invalid port forward spec: {spec}")]
InvalidSpec { spec: String },
#[error("bind failed")]
BindFailed {
#[source]
source: io::Error,
},
#[error("channel open failed")]
ChannelOpenFailed {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("connect to local target failed")]
LocalConnectFailed {
#[source]
source: io::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn transport_error_display() {
assert_eq!(
TransportError::ConnectionFailed.to_string(),
"connection failed"
);
assert_eq!(
TransportError::HandshakeFailed {
source: io::Error::new(io::ErrorKind::ConnectionRefused, "tls failed")
}
.to_string(),
"handshake failed"
);
assert_eq!(TransportError::Timeout.to_string(), "transport timeout");
assert_eq!(
TransportError::ProxyFailed {
source: io::Error::new(io::ErrorKind::ConnectionRefused, "proxy err")
}
.to_string(),
"proxy failed"
);
}
#[test]
fn auth_error_display() {
assert_eq!(AuthError::KeyRejected.to_string(), "key rejected");
assert_eq!(AuthError::CertInvalid.to_string(), "certificate invalid");
assert_eq!(AuthError::CertExpired.to_string(), "certificate expired");
assert_eq!(
AuthError::CertPrincipalMismatch.to_string(),
"certificate principal mismatch"
);
assert_eq!(AuthError::NoMatchingKey.to_string(), "no matching key");
}
#[test]
fn channel_error_display() {
assert_eq!(
ChannelError::TargetUnreachable.to_string(),
"target unreachable"
);
assert_eq!(
ChannelError::ProxyConnectFailed {
source: io::Error::new(io::ErrorKind::ConnectionRefused, "refused")
}
.to_string(),
"proxy connect failed"
);
assert_eq!(ChannelError::ChannelClosed.to_string(), "channel closed");
}
#[test]
fn config_error_display() {
assert_eq!(
ConfigError::InvalidFlag {
name: "--bad".to_string()
}
.to_string(),
"invalid flag: --bad"
);
assert_eq!(
ConfigError::KeyFileNotFound {
path: "/missing".to_string()
}
.to_string(),
"key file not found: /missing"
);
assert_eq!(
ConfigError::BindFailed {
source: io::Error::new(io::ErrorKind::AddrInUse, "in use")
}
.to_string(),
"bind failed"
);
assert_eq!(
ConfigError::IncompatibleOptions.to_string(),
"incompatible options"
);
assert_eq!(
ConfigError::ProxyConfigInvalid {
message: "bad proxy".to_string()
}
.to_string(),
"invalid proxy config: bad proxy"
);
}
#[test]
fn error_source_chaining() {
let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
let transport_err = TransportError::HandshakeFailed { source: io_err };
assert!(transport_err.source().is_some());
let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "proxy");
let channel_err = ChannelError::ProxyConnectFailed { source: io_err };
assert!(channel_err.source().is_some());
let io_err = io::Error::new(io::ErrorKind::AddrInUse, "addr");
let config_err = ConfigError::BindFailed { source: io_err };
assert!(config_err.source().is_some());
let plain = AuthError::KeyRejected;
assert!(plain.source().is_none());
}
#[test]
fn forward_error_display() {
assert_eq!(
ForwardError::InvalidSpec {
spec: "bad".to_string()
}
.to_string(),
"invalid port forward spec: bad"
);
assert_eq!(
ForwardError::BindFailed {
source: io::Error::new(io::ErrorKind::AddrInUse, "in use")
}
.to_string(),
"bind failed"
);
assert_eq!(
ForwardError::LocalConnectFailed {
source: io::Error::new(io::ErrorKind::ConnectionRefused, "refused")
}
.to_string(),
"connect to local target failed"
);
}
#[test]
fn forward_error_source_chaining() {
let io_err = io::Error::new(io::ErrorKind::AddrInUse, "in use");
let forward_err = ForwardError::BindFailed { source: io_err };
assert!(forward_err.source().is_some());
let plain = ForwardError::InvalidSpec {
spec: "bad".to_string(),
};
assert!(plain.source().is_none());
}
}

View File

@@ -0,0 +1,264 @@
//! TLS certificate fingerprint extraction (ADR-030 §6, ADR-034 §3).
//!
//! Fingerprint formats:
//! - **Ed25519 raw key** (RFC 7250 SPKI): `ed25519:<hex of 32-byte pub key>`.
//! The fingerprint IS the trust anchor — raw-key remotes have no CA, so the
//! fingerprint is the identity (ADR-034 §2 assumption 1). Normalized to
//! `ed25519:<hex>` across quinn and iroh (ADR-030 §6).
//! - **X.509 cert**: `SHA256:<hex of DER>`. Used by the hub X.509 path
//! (ADR-034 §3 — fingerprint pinning for known hubs with a prior P2P trust
//! relationship). Not used for arbitrary public APIs (those use CA
//! verification via `WebPkiServerVerifier`, not fingerprint pinning).
//!
//! Shared by the server-side endpoint (`alknet_core::endpoint`, which extracts
//! the fingerprint from the presented client cert for `PeerEntry` resolution)
//! and the client-side `FingerprintPinVerifier` in `alknet_call::client`
//! (which matches the server's presented cert against a pinned fingerprint).
use sha2::{Digest, Sha256};
/// Compute the fingerprint of a TLS certificate DER (RFC 7250 raw public key
/// SPKI or X.509 cert). Returns `ed25519:<hex>` when `cert_der` is an Ed25519
/// SPKI, otherwise `SHA256:<hex of full DER>`. Returns `None` only when the
/// input is empty (a non-Ed25519 SPKI or a malformed DER still hashes to a
/// `SHA256:` fingerprint — the hash is the fallback).
pub fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option<String> {
if let Some(raw_key) = extract_ed25519_raw_key_from_spki(cert_der) {
return Some(format!("ed25519:{}", hex::encode(raw_key)));
}
let mut hasher = Sha256::new();
hasher.update(cert_der);
let digest = hasher.finalize();
Some(format!("SHA256:{}", hex::encode(digest)))
}
/// `SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }`
/// `AlgorithmIdentifier ::= SEQUENCE { algorithm OBJECT IDENTIFIER, parameters ANY OPTIONAL }`
/// For Ed25519 the algorithm OID is `1.3.101.112` (DER bytes `2b 65 70`), with
/// no parameters, and `subjectPublicKey` is a BIT STRING containing one
/// unused-bits byte (`0x00`) followed by the 32-byte raw Ed25519 public key.
/// Returns the 32 raw key bytes when `cert_der` is an RFC 7250 raw public key
/// (SPKI) with the Ed25519 algorithm identifier; returns `None` otherwise
/// (X.509 cert, non-Ed25519 SPKI, or malformed DER), in which case callers
/// should fall back to hashing the full DER.
pub fn extract_ed25519_raw_key_from_spki(cert_der: &[u8]) -> Option<[u8; 32]> {
const ED25519_OID_BYTES: [u8; 3] = [0x2b, 0x65, 0x70];
let mut parser = DerParser::new(cert_der);
let spki_contents = parser.expect_sequence()?;
let mut spki_parser = DerParser::new(spki_contents);
let alg_id_contents = spki_parser.expect_sequence()?;
let mut alg_id_parser = DerParser::new(alg_id_contents);
let oid_bytes = alg_id_parser.expect_oid()?;
if oid_bytes != ED25519_OID_BYTES {
return None;
}
let bit_string_contents = spki_parser.expect_bit_string()?;
if bit_string_contents.len() != 33 || bit_string_contents[0] != 0x00 {
return None;
}
let mut raw_key = [0u8; 32];
raw_key.copy_from_slice(&bit_string_contents[1..33]);
Some(raw_key)
}
struct DerParser<'a> {
bytes: &'a [u8],
}
impl<'a> DerParser<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes }
}
fn read_tlv(&mut self) -> Option<(u8, &'a [u8])> {
let (tag, len_size, header_len) = self.decode_header()?;
let total = header_len.checked_add(len_size)?;
if total > self.bytes.len() {
return None;
}
let content = &self.bytes[header_len..total];
self.bytes = &self.bytes[total..];
Some((tag, content))
}
fn decode_header(&self) -> Option<(u8, usize, usize)> {
if self.bytes.is_empty() {
return None;
}
let tag = self.bytes[0];
if self.bytes.len() < 2 {
return None;
}
let first_len = self.bytes[1];
if first_len < 0x80 {
return Some((tag, first_len as usize, 2));
}
let num_bytes = (first_len & 0x7f) as usize;
if num_bytes == 0 || num_bytes > 4 {
return None;
}
if self.bytes.len() < 2 + num_bytes {
return None;
}
let mut len: usize = 0;
for i in 0..num_bytes {
len = (len << 8) | (self.bytes[2 + i] as usize);
}
Some((tag, len, 2 + num_bytes))
}
fn expect_sequence(&mut self) -> Option<&'a [u8]> {
let (tag, content) = self.read_tlv()?;
if tag == 0x30 {
Some(content)
} else {
None
}
}
fn expect_oid(&mut self) -> Option<&'a [u8]> {
let (tag, content) = self.read_tlv()?;
if tag == 0x06 {
Some(content)
} else {
None
}
}
fn expect_bit_string(&mut self) -> Option<&'a [u8]> {
let (tag, content) = self.read_tlv()?;
if tag == 0x03 {
Some(content)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec<u8> {
let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key);
spki.to_vec()
}
#[test]
fn fingerprint_from_cert_der_produces_sha256_hex_format() {
let cert_der = b"fake-leaf-cert-der-bytes";
let fp = fingerprint_from_cert_der(cert_der).expect("non-empty cert produces fingerprint");
assert!(
fp.starts_with("SHA256:"),
"fingerprint must be SHA256-prefixed, got: {fp}"
);
let hex_part = &fp["SHA256:".len()..];
assert_eq!(
hex_part.len(),
64,
"hex digest must be 64 chars (32 bytes), got: {fp}"
);
assert!(
hex_part.chars().all(|c| c.is_ascii_hexdigit()),
"hex part must be lowercase hex, got: {fp}"
);
let mut hasher = Sha256::new();
hasher.update(cert_der);
let expected = format!("SHA256:{}", hex::encode(hasher.finalize()));
assert_eq!(fp, expected, "fingerprint must match SHA-256 of cert DER");
}
#[test]
fn fingerprint_from_cert_der_deterministic() {
let cert = b"some-cert";
let a = fingerprint_from_cert_der(cert).unwrap();
let b = fingerprint_from_cert_der(cert).unwrap();
assert_eq!(a, b, "same cert DER must produce same fingerprint");
}
#[test]
fn fingerprint_from_ed25519_spki_produces_ed25519_prefix() {
let sk = crate::config::Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint");
assert!(
fp.starts_with("ed25519:"),
"Ed25519 raw key SPKI must produce ed25519: fingerprint, got: {fp}"
);
}
#[test]
fn fingerprint_from_ed25519_spki_is_lowercase_hex_of_32_byte_key() {
let sk = crate::config::Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint");
let hex_part = &fp["ed25519:".len()..];
assert_eq!(
hex_part.len(),
64,
"ed25519 hex part must be 64 chars (32 bytes), got: {fp}"
);
assert!(
hex_part
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"ed25519 hex part must be lowercase hex, got: {fp}"
);
assert_eq!(
hex_part,
hex::encode(raw_key),
"ed25519 fingerprint must be hex of the raw 32-byte key, not the DER wrapper"
);
}
#[test]
fn fingerprint_from_ed25519_spki_matches_iroh_format() {
let sk = crate::config::Ed25519SecretKey::generate();
let raw_key = sk.public().to_bytes();
let spki_der = build_ed25519_spki_der(&raw_key);
let quinn_fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint");
let iroh_fp = format!("ed25519:{}", hex::encode(raw_key));
assert_eq!(
quinn_fp, iroh_fp,
"same Ed25519 key must produce the same fingerprint via quinn SPKI and iroh NodeId paths"
);
}
#[test]
fn fingerprint_from_x509_cert_stays_sha256_of_der() {
let cert_der = b"fake-x509-cert-der-bytes-not-an-spki";
let fp = fingerprint_from_cert_der(cert_der).expect("x509 produces fingerprint");
assert!(
fp.starts_with("SHA256:"),
"X.509 cert must keep SHA256: format, got: {fp}"
);
let mut hasher = Sha256::new();
hasher.update(cert_der);
assert_eq!(
fp,
format!("SHA256:{}", hex::encode(hasher.finalize())),
"X.509 fingerprint must be SHA-256 of cert DER"
);
}
#[test]
fn fingerprint_from_non_ed25519_spki_falls_back_to_sha256() {
let raw_key = [0u8; 32];
let fake_non_ed25519_spki: Vec<u8> = vec![
0x30, 0x1c, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x06, 0x01, 0x03, 0x15, 0x00, 0x20,
]
.into_iter()
.chain(raw_key.iter().copied())
.collect();
let fp = fingerprint_from_cert_der(&fake_non_ed25519_spki).expect("fallback fingerprint");
assert!(
fp.starts_with("SHA256:"),
"non-Ed25519 SPKI must fall back to SHA256, got: {fp}"
);
}
}

View File

@@ -1,182 +0,0 @@
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use crate::auth::{AuthToken, Identity, IdentityProvider};
#[derive(Clone)]
pub struct IdentityExt(pub Identity);
pub async fn auth_middleware(
axum::extract::State(identity_provider): axum::extract::State<
std::sync::Arc<dyn IdentityProvider>,
>,
mut request: Request,
next: Next,
) -> Response {
let auth_header = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let token_str = match auth_header {
Some(h) if h.starts_with("Bearer ") => &h[7..],
_ => {
return axum::http::StatusCode::UNAUTHORIZED.into_response();
}
};
let token = AuthToken {
raw: token_str.as_bytes().to_vec(),
};
match identity_provider.resolve_from_token(&token) {
Some(identity) => {
request.extensions_mut().insert(IdentityExt(identity));
next.run(request).await
}
None => axum::http::StatusCode::UNAUTHORIZED.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request as HttpRequest, StatusCode};
use axum::routing::get;
use axum::Router;
use std::collections::HashMap;
use std::sync::Arc;
use tower::ServiceExt;
struct MockIdentityProvider {
valid_token: String,
identity: Identity,
}
impl IdentityProvider for MockIdentityProvider {
fn resolve_from_fingerprint(&self, _fingerprint: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
let token_str = String::from_utf8_lossy(&token.raw);
if token_str == self.valid_token {
Some(self.identity.clone())
} else {
None
}
}
}
fn make_provider(valid_token: &str) -> Arc<dyn IdentityProvider> {
let identity = Identity {
id: "test-user".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
Arc::new(MockIdentityProvider {
valid_token: valid_token.to_string(),
identity,
})
}
#[tokio::test]
async fn auth_middleware_extracts_bearer_token() {
let provider = make_provider("alk_validtoken1");
let app = Router::new()
.route(
"/test",
get(|request: Request| async move {
let has_identity = request.extensions().get::<IdentityExt>().is_some();
if has_identity {
StatusCode::OK.into_response()
} else {
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}),
)
.layer(axum::middleware::from_fn_with_state(
provider,
auth_middleware,
));
let req = HttpRequest::builder()
.uri("/test")
.header("authorization", "Bearer alk_validtoken1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn auth_middleware_returns_401_for_missing_token() {
let provider = make_provider("alk_validtoken1");
let app = Router::new()
.route("/test", get(|| async { StatusCode::OK.into_response() }))
.layer(axum::middleware::from_fn_with_state(
provider,
auth_middleware,
));
let req = HttpRequest::builder()
.uri("/test")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn auth_middleware_returns_401_for_invalid_token() {
let provider = make_provider("alk_validtoken1");
let app = Router::new()
.route("/test", get(|| async { StatusCode::OK.into_response() }))
.layer(axum::middleware::from_fn_with_state(
provider,
auth_middleware,
));
let req = HttpRequest::builder()
.uri("/test")
.header("authorization", "Bearer alk_wrongtoken1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn auth_middleware_attaches_identity_to_extensions() {
let provider = make_provider("alk_testidentity1");
let app = Router::new()
.route(
"/test",
get(|request: Request| async move {
let identity = request.extensions().get::<IdentityExt>().unwrap();
identity.0.id.clone()
}),
)
.layer(axum::middleware::from_fn_with_state(
provider,
auth_middleware,
));
let req = HttpRequest::builder()
.uri("/test")
.header("authorization", "Bearer alk_testidentity1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
assert_eq!(&body[..], b"test-user");
}
}

View File

@@ -1,5 +0,0 @@
pub mod auth;
pub mod router;
pub use auth::IdentityExt;
pub use router::{build_router, serve_connection};

View File

@@ -1,150 +0,0 @@
use std::sync::Arc;
use axum::response::IntoResponse;
use axum::Router;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder;
use hyper_util::service::TowerToHyperService;
use tokio::io::{AsyncRead, AsyncWrite, BufReader};
use crate::auth::IdentityProvider;
use crate::http::auth::auth_middleware;
async fn default_404() -> impl IntoResponse {
axum::http::StatusCode::NOT_FOUND
}
pub fn build_router(identity_provider: Arc<dyn IdentityProvider>) -> Router {
Router::new()
.fallback(default_404)
.layer(axum::middleware::from_fn_with_state(
identity_provider,
auth_middleware,
))
}
pub async fn serve_connection<S>(stream: S, identity_provider: Arc<dyn IdentityProvider>)
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let app = build_router(identity_provider);
let io = TokioIo::new(stream);
let hyper_service = TowerToHyperService::new(app.into_service::<hyper::body::Incoming>());
let result = Builder::new(TokioExecutor::new())
.serve_connection_with_upgrades(io, hyper_service)
.await;
if let Err(e) = result {
tracing::debug!("http connection error: {e}");
}
}
pub async fn serve_connection_from_reader<S>(
reader: BufReader<S>,
identity_provider: Arc<dyn IdentityProvider>,
) where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
serve_connection(reader, identity_provider).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{AuthToken, Identity};
use axum::body::Body;
use axum::http::{Request as HttpRequest, StatusCode};
use axum::response::IntoResponse;
use std::collections::HashMap;
use std::sync::Arc;
use tower::ServiceExt;
struct NullIdentityProvider;
impl IdentityProvider for NullIdentityProvider {
fn resolve_from_fingerprint(&self, _fingerprint: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
None
}
}
#[tokio::test]
async fn default_404_handler_returns_not_found() {
let provider: Arc<dyn IdentityProvider> = Arc::new(MockValidProvider);
let app = build_router(provider);
let req = HttpRequest::builder()
.uri("/anything")
.header("authorization", "Bearer alk_sometoken1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn missing_auth_returns_401_before_404() {
let provider: Arc<dyn IdentityProvider> = Arc::new(MockValidProvider);
let app = build_router(provider);
let req = HttpRequest::builder()
.uri("/anything")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn invalid_auth_returns_401_before_404() {
let provider: Arc<dyn IdentityProvider> = Arc::new(NullIdentityProvider);
let app = build_router(provider);
let req = HttpRequest::builder()
.uri("/anything")
.header("authorization", "Bearer alk_sometoken1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unmatched_route_returns_404_with_valid_auth() {
let provider: Arc<dyn IdentityProvider> = Arc::new(MockValidProvider);
let app = build_router(provider);
let req = HttpRequest::builder()
.uri("/v1/unknown/op")
.header("authorization", "Bearer alk_valid")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
struct MockValidProvider;
impl IdentityProvider for MockValidProvider {
fn resolve_from_fingerprint(&self, _fingerprint: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
Some(Identity {
id: "test".to_string(),
scopes: vec![],
resources: HashMap::new(),
})
}
}
}

View File

@@ -1,270 +0,0 @@
use std::sync::Arc;
use arc_swap::ArcSwap;
use russh::keys::PrivateKey;
use serde::{Deserialize, Serialize};
use crate::auth::IdentityProvider;
use crate::config::DynamicConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StreamInterfaceKind {
Ssh,
RawFraming,
}
impl std::fmt::Display for StreamInterfaceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StreamInterfaceKind::Ssh => write!(f, "ssh"),
StreamInterfaceKind::RawFraming => write!(f, "raw-framing"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MessageInterfaceKind {
Http,
Dns,
}
impl std::fmt::Display for MessageInterfaceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MessageInterfaceKind::Http => write!(f, "http"),
MessageInterfaceKind::Dns => write!(f, "dns"),
}
}
}
#[non_exhaustive]
pub enum InterfaceConfig {
Ssh(SshInterfaceConfig),
RawFraming(RawFramingConfig),
}
impl InterfaceConfig {
pub fn kind(&self) -> StreamInterfaceKind {
#[allow(unreachable_patterns)]
match self {
InterfaceConfig::Ssh(_) => StreamInterfaceKind::Ssh,
InterfaceConfig::RawFraming(_) => StreamInterfaceKind::RawFraming,
_ => StreamInterfaceKind::Ssh,
}
}
}
#[non_exhaustive]
pub enum StreamInterfaceConfig {
Ssh(SshInterfaceConfig),
RawFraming(RawFramingConfig),
}
impl StreamInterfaceConfig {
pub fn kind(&self) -> StreamInterfaceKind {
match self {
StreamInterfaceConfig::Ssh(_) => StreamInterfaceKind::Ssh,
StreamInterfaceConfig::RawFraming(_) => StreamInterfaceKind::RawFraming,
}
}
}
impl std::fmt::Display for StreamInterfaceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StreamInterfaceConfig::Ssh(_) => write!(f, "ssh"),
StreamInterfaceConfig::RawFraming(_) => write!(f, "raw-framing"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MessageInterfaceConfig {
Http(HttpInterfaceConfig),
Dns(DnsInterfaceConfig),
}
impl MessageInterfaceConfig {
pub fn kind(&self) -> MessageInterfaceKind {
match self {
MessageInterfaceConfig::Http(_) => MessageInterfaceKind::Http,
MessageInterfaceConfig::Dns(_) => MessageInterfaceKind::Dns,
}
}
}
impl std::fmt::Display for MessageInterfaceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MessageInterfaceConfig::Http(_) => write!(f, "http"),
MessageInterfaceConfig::Dns(_) => write!(f, "dns"),
}
}
}
pub struct SshInterfaceConfig {
pub auth: Arc<dyn IdentityProvider>,
pub forwarding: Arc<ArcSwap<DynamicConfig>>,
pub host_key: Arc<PrivateKey>,
}
pub struct RawFramingConfig {
pub auth: Arc<dyn IdentityProvider>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HttpInterfaceConfig {
pub bind_addr: std::net::SocketAddr,
pub tls: bool,
pub stealth: bool,
}
impl std::fmt::Display for HttpInterfaceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "http {}", self.bind_addr)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DnsInterfaceConfig {
pub bind_addr: std::net::SocketAddr,
pub tls: bool,
}
impl std::fmt::Display for DnsInterfaceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "dns {}", self.bind_addr)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::ConfigIdentityProvider;
#[test]
fn stream_interface_kind_display() {
assert_eq!(StreamInterfaceKind::Ssh.to_string(), "ssh");
assert_eq!(StreamInterfaceKind::RawFraming.to_string(), "raw-framing");
}
#[test]
fn message_interface_kind_display() {
assert_eq!(MessageInterfaceKind::Http.to_string(), "http");
assert_eq!(MessageInterfaceKind::Dns.to_string(), "dns");
}
#[test]
fn stream_interface_config_kind() {
let auth = Arc::new(crate::auth::ConfigIdentityProvider::new(Arc::new(
ArcSwap::new(Arc::new(DynamicConfig::default())),
)));
let ssh_config = StreamInterfaceConfig::Ssh(SshInterfaceConfig {
auth,
forwarding: Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default()))),
host_key: Arc::new(
russh::keys::PrivateKey::random(
&mut rand_core::OsRng,
russh::keys::Algorithm::Ed25519,
)
.unwrap(),
),
});
assert_eq!(ssh_config.kind(), StreamInterfaceKind::Ssh);
let raw_config = StreamInterfaceConfig::RawFraming(RawFramingConfig {
auth: Arc::new(ConfigIdentityProvider::new(Arc::new(ArcSwap::new(
Arc::new(DynamicConfig::default()),
)))),
});
assert_eq!(raw_config.kind(), StreamInterfaceKind::RawFraming);
}
#[test]
fn message_interface_config_kind() {
let http_config = MessageInterfaceConfig::Http(HttpInterfaceConfig {
bind_addr: "127.0.0.1:8080".parse().unwrap(),
tls: false,
stealth: false,
});
assert_eq!(http_config.kind(), MessageInterfaceKind::Http);
let dns_config = MessageInterfaceConfig::Dns(DnsInterfaceConfig {
bind_addr: "127.0.0.1:53".parse().unwrap(),
tls: false,
});
assert_eq!(dns_config.kind(), MessageInterfaceKind::Dns);
}
#[test]
fn stream_interface_kind_equality() {
assert_eq!(StreamInterfaceKind::Ssh, StreamInterfaceKind::Ssh);
assert_eq!(
StreamInterfaceKind::RawFraming,
StreamInterfaceKind::RawFraming
);
assert_ne!(StreamInterfaceKind::Ssh, StreamInterfaceKind::RawFraming);
}
#[test]
fn message_interface_kind_equality() {
assert_eq!(MessageInterfaceKind::Http, MessageInterfaceKind::Http);
assert_eq!(MessageInterfaceKind::Dns, MessageInterfaceKind::Dns);
assert_ne!(MessageInterfaceKind::Http, MessageInterfaceKind::Dns);
}
#[test]
fn raw_framing_config_minimal() {
let auth: Arc<dyn IdentityProvider> = Arc::new(ConfigIdentityProvider::new(Arc::new(
ArcSwap::new(Arc::new(DynamicConfig::default())),
)));
let _config = RawFramingConfig { auth };
}
#[test]
fn http_interface_config_display() {
let config = HttpInterfaceConfig {
bind_addr: "127.0.0.1:8080".parse().unwrap(),
tls: true,
stealth: true,
};
assert_eq!(config.to_string(), "http 127.0.0.1:8080");
}
#[test]
fn dns_interface_config_display() {
let config = DnsInterfaceConfig {
bind_addr: "127.0.0.1:53".parse().unwrap(),
tls: false,
};
assert_eq!(config.to_string(), "dns 127.0.0.1:53");
}
#[test]
fn http_interface_config_serialization() {
let config = HttpInterfaceConfig {
bind_addr: "127.0.0.1:8080".parse().unwrap(),
tls: true,
stealth: false,
};
let serialized = serde_json::to_string(&config).unwrap();
let deserialized: HttpInterfaceConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.bind_addr, config.bind_addr);
assert_eq!(deserialized.tls, config.tls);
}
#[test]
fn dns_interface_config_serialization() {
let config = DnsInterfaceConfig {
bind_addr: "0.0.0.0:53".parse().unwrap(),
tls: true,
};
let serialized = serde_json::to_string(&config).unwrap();
let deserialized: DnsInterfaceConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.bind_addr, config.bind_addr);
assert_eq!(deserialized.tls, config.tls);
}
}

View File

@@ -1,47 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use crate::call::OperationEnv;
use crate::interface::{InterfaceRequest, InterfaceResponse, MessageInterface};
pub struct DnsInterface {
pub domain: String,
pub identity_provider: Arc<dyn crate::auth::IdentityProvider>,
pub registry: Arc<crate::call::OperationRegistry>,
pub env: OperationEnv,
}
#[async_trait]
impl MessageInterface for DnsInterface {
async fn handle_request(&self, _request: InterfaceRequest) -> Result<InterfaceResponse> {
Ok(InterfaceResponse {
result: Err(crate::call::CallError::new(
"NOT_IMPLEMENTED",
"DnsInterface is not yet implemented",
false,
)),
status: 501,
headers: std::collections::HashMap::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dns_interface_type_exists() {
let registry = Arc::new(crate::call::OperationRegistry::new());
let _iface = DnsInterface {
domain: "alk.dev".to_string(),
identity_provider: Arc::new(crate::auth::ConfigIdentityProvider::new(Arc::new(
arc_swap::ArcSwap::new(Arc::new(crate::config::DynamicConfig::default())),
))),
env: OperationEnv::local(crate::call::OperationRegistry::new()),
registry,
};
}
}

View File

@@ -1,66 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use crate::call::OperationEnv;
use crate::interface::{InterfaceRequest, InterfaceResponse, MessageInterface};
pub struct HttpInterface {
pub identity_provider: Arc<dyn crate::auth::IdentityProvider>,
pub registry: Arc<crate::call::OperationRegistry>,
pub env: OperationEnv,
}
#[async_trait]
impl MessageInterface for HttpInterface {
async fn handle_request(&self, _request: InterfaceRequest) -> Result<InterfaceResponse> {
Ok(InterfaceResponse {
result: Err(crate::call::CallError::new(
"NOT_IMPLEMENTED",
"HttpInterface is not yet implemented",
false,
)),
status: 501,
headers: std::collections::HashMap::new(),
})
}
}
#[cfg(feature = "http")]
impl HttpInterface {
pub fn build_router(&self) -> axum::Router {
crate::http::router::build_router(Arc::clone(&self.identity_provider))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_interface_type_exists() {
let registry = Arc::new(crate::call::OperationRegistry::new());
let _iface = HttpInterface {
identity_provider: Arc::new(crate::auth::ConfigIdentityProvider::new(Arc::new(
arc_swap::ArcSwap::new(Arc::new(crate::config::DynamicConfig::default())),
))),
env: OperationEnv::local(crate::call::OperationRegistry::new()),
registry,
};
}
#[cfg(feature = "http")]
#[test]
fn http_interface_builds_router() {
let registry = Arc::new(crate::call::OperationRegistry::new());
let iface = HttpInterface {
identity_provider: Arc::new(crate::auth::ConfigIdentityProvider::new(Arc::new(
arc_swap::ArcSwap::new(Arc::new(crate::config::DynamicConfig::default())),
))),
env: OperationEnv::local(crate::call::OperationRegistry::new()),
registry,
};
let _router = iface.build_router();
}
}

View File

@@ -1,140 +0,0 @@
//! Interface layer (Layer 2) of the three-layer model (ADR-026, ADR-035).
//!
//! The Interface layer sits between Transport (Layer 1) and Protocol (Layer 3).
//! It has two distinct patterns:
//!
//! - **StreamInterface** — consumes a `TransportStream`, produces a long-lived
//! `Session` that yields `InterfaceEvent` frames. SSH and raw framing are
//! `StreamInterface` implementations.
//!
//! - **MessageInterface** — handles individual `InterfaceRequest` →
//! `InterfaceResponse` pairs. Manages its own transport (HTTP server, DNS
//! server). HTTP and DNS are `MessageInterface` implementations.
pub mod config;
pub mod dns;
pub mod http;
pub mod pairs;
pub mod raw_framing;
pub mod session;
pub mod ssh;
use std::collections::HashMap;
use anyhow::Result;
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite};
pub use config::{
DnsInterfaceConfig, HttpInterfaceConfig, InterfaceConfig, MessageInterfaceConfig,
MessageInterfaceKind, RawFramingConfig, SshInterfaceConfig, StreamInterfaceConfig,
StreamInterfaceKind,
};
pub use dns::DnsInterface;
pub use http::HttpInterface;
pub use pairs::{is_valid_pair, TransportKindBase, VALID_TRANSPORT_INTERFACE_PAIRS};
pub use raw_framing::{RawFramingInterface, RawFramingSession};
pub use session::{InterfaceEvent, InterfaceSession};
pub use ssh::{ControlChannelBridge, SshInterface, SshSession};
pub trait TransportStream: AsyncRead + AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> TransportStream for T {}
#[async_trait]
pub trait StreamInterface: Send + Sync + 'static {
type Session: InterfaceSession;
async fn accept(
&self,
stream: Box<dyn TransportStream>,
config: &StreamInterfaceConfig,
) -> Result<Self::Session>;
}
#[async_trait]
pub trait MessageInterface: Send + Sync + 'static {
async fn handle_request(&self, request: InterfaceRequest) -> Result<InterfaceResponse>;
}
#[derive(Debug, Clone)]
pub struct InterfaceRequest {
pub operation_path: String,
pub input: serde_json::Value,
pub auth_token: Option<crate::auth::AuthToken>,
pub metadata: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct InterfaceResponse {
pub result: Result<serde_json::Value, crate::call::CallError>,
pub status: u16,
pub headers: HashMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::duplex;
#[test]
fn transport_stream_trait_bounds() {
fn assert_transport_stream<S: TransportStream>() {}
assert_transport_stream::<tokio::io::DuplexStream>();
}
#[tokio::test]
async fn transport_stream_from_duplex() {
let (client, server) = duplex(1024);
let _boxed: Box<dyn TransportStream> = Box::new(server);
let _: Box<dyn TransportStream> = Box::new(client);
}
#[test]
fn interface_request_fields() {
let req = InterfaceRequest {
operation_path: "/v1/head/auth/verify".to_string(),
input: serde_json::json!({"key": "value"}),
auth_token: None,
metadata: HashMap::new(),
};
assert_eq!(req.operation_path, "/v1/head/auth/verify");
assert!(req.auth_token.is_none());
}
#[test]
fn interface_response_fields() {
let resp = InterfaceResponse {
result: Ok(serde_json::json!({"status": "ok"})),
status: 200,
headers: HashMap::new(),
};
assert_eq!(resp.status, 200);
}
struct MockMessageInterface;
#[async_trait]
impl MessageInterface for MockMessageInterface {
async fn handle_request(&self, _request: InterfaceRequest) -> Result<InterfaceResponse> {
Ok(InterfaceResponse {
result: Ok(serde_json::json!({})),
status: 200,
headers: HashMap::new(),
})
}
}
#[tokio::test]
async fn message_interface_trait_compiles() {
let iface = MockMessageInterface;
let req = InterfaceRequest {
operation_path: "/test".to_string(),
input: serde_json::json!({}),
auth_token: None,
metadata: HashMap::new(),
};
let resp = iface.handle_request(req).await.unwrap();
assert_eq!(resp.status, 200);
}
}

View File

@@ -1,122 +0,0 @@
use crate::transport::TransportKind;
use super::config::StreamInterfaceKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportKindBase {
Tcp,
Tls,
Iroh,
WebTransport,
}
fn transport_base(kind: &TransportKind) -> TransportKindBase {
match kind {
TransportKind::Tcp => TransportKindBase::Tcp,
TransportKind::Tls { .. } => TransportKindBase::Tls,
TransportKind::Iroh { .. } => TransportKindBase::Iroh,
TransportKind::WebTransport { .. } => TransportKindBase::WebTransport,
}
}
pub fn is_valid_pair(transport: &TransportKind, interface: StreamInterfaceKind) -> bool {
let base = transport_base(transport);
matches!(
(base, interface),
(TransportKindBase::Tcp, StreamInterfaceKind::Ssh)
| (TransportKindBase::Tls, StreamInterfaceKind::Ssh)
| (TransportKindBase::Iroh, StreamInterfaceKind::Ssh)
| (TransportKindBase::WebTransport, StreamInterfaceKind::Ssh)
| (
TransportKindBase::WebTransport,
StreamInterfaceKind::RawFraming
)
| (TransportKindBase::Tcp, StreamInterfaceKind::RawFraming)
)
}
pub const VALID_TRANSPORT_INTERFACE_PAIRS: &[(TransportKindBase, StreamInterfaceKind)] = &[
(TransportKindBase::Tcp, StreamInterfaceKind::Ssh),
(TransportKindBase::Tls, StreamInterfaceKind::Ssh),
(TransportKindBase::Iroh, StreamInterfaceKind::Ssh),
(TransportKindBase::WebTransport, StreamInterfaceKind::Ssh),
(
TransportKindBase::WebTransport,
StreamInterfaceKind::RawFraming,
),
(TransportKindBase::Tcp, StreamInterfaceKind::RawFraming),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_ssh_pairs() {
assert!(is_valid_pair(&TransportKind::Tcp, StreamInterfaceKind::Ssh));
assert!(is_valid_pair(
&TransportKind::Tls { server_name: None },
StreamInterfaceKind::Ssh
));
assert!(is_valid_pair(
&TransportKind::Iroh {
endpoint_id: String::new()
},
StreamInterfaceKind::Ssh
));
assert!(is_valid_pair(
&TransportKind::WebTransport { server_name: None },
StreamInterfaceKind::Ssh
));
}
#[test]
fn valid_raw_framing_pairs() {
assert!(is_valid_pair(
&TransportKind::Tcp,
StreamInterfaceKind::RawFraming
));
assert!(is_valid_pair(
&TransportKind::WebTransport { server_name: None },
StreamInterfaceKind::RawFraming
));
}
#[test]
fn invalid_pairs() {
assert!(!is_valid_pair(
&TransportKind::Iroh {
endpoint_id: String::new()
},
StreamInterfaceKind::RawFraming
));
}
#[test]
fn transport_kind_base_classification() {
assert_eq!(transport_base(&TransportKind::Tcp), TransportKindBase::Tcp);
assert_eq!(
transport_base(&TransportKind::Tls {
server_name: Some("example.com".to_string())
}),
TransportKindBase::Tls
);
assert_eq!(
transport_base(&TransportKind::Iroh {
endpoint_id: "abc".to_string()
}),
TransportKindBase::Iroh
);
assert_eq!(
transport_base(&TransportKind::WebTransport {
server_name: Some("example.com".to_string())
}),
TransportKindBase::WebTransport
);
}
#[test]
fn valid_pairs_table_complete() {
assert_eq!(VALID_TRANSPORT_INTERFACE_PAIRS.len(), 6);
}
}

View File

@@ -1,399 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader, BufWriter};
use crate::auth::{AuthToken, Identity, IdentityProvider};
use crate::call::frame::{decode_with_remainder, encode};
use crate::call::EventEnvelope;
use crate::interface::session::{InterfaceEvent, InterfaceSession};
use crate::interface::{StreamInterface, StreamInterfaceConfig, TransportStream};
const READ_BUF_SIZE: usize = 8192;
pub struct RawFramingInterface;
#[async_trait]
impl StreamInterface for RawFramingInterface {
type Session = RawFramingSession;
async fn accept(
&self,
stream: Box<dyn TransportStream>,
config: &StreamInterfaceConfig,
) -> Result<Self::Session> {
let raw_config = match config {
StreamInterfaceConfig::RawFraming(c) => c,
StreamInterfaceConfig::Ssh(_) => {
return Err(anyhow::anyhow!(
"RawFramingInterface received SshInterfaceConfig"
));
}
};
Ok(RawFramingSession::new(stream, Arc::clone(&raw_config.auth)))
}
}
enum AuthState {
Pending,
Authenticated(Identity),
Failed,
}
pub struct RawFramingSession {
reader: BufReader<tokio::io::ReadHalf<Box<dyn TransportStream>>>,
writer: BufWriter<tokio::io::WriteHalf<Box<dyn TransportStream>>>,
auth_state: AuthState,
identity_provider: Arc<dyn IdentityProvider>,
read_buf: Vec<u8>,
}
impl RawFramingSession {
pub fn new(
stream: Box<dyn TransportStream>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
let (read_half, write_half) = tokio::io::split(stream);
Self {
reader: BufReader::new(read_half),
writer: BufWriter::new(write_half),
auth_state: AuthState::Pending,
identity_provider,
read_buf: Vec::new(),
}
}
async fn read_frame(&mut self) -> Result<EventEnvelope> {
loop {
match decode_with_remainder(&self.read_buf) {
Ok((envelope, consumed)) => {
self.read_buf.drain(..consumed);
return Ok(envelope);
}
Err(crate::call::frame::FrameDecodeError::TooShort { .. })
| Err(crate::call::frame::FrameDecodeError::Incomplete { .. }) => {
let mut tmp = [0u8; READ_BUF_SIZE];
let n = self.reader.read(&mut tmp).await?;
if n == 0 {
return Err(anyhow::anyhow!("stream closed while reading frame"));
}
self.read_buf.extend_from_slice(&tmp[..n]);
}
Err(crate::call::frame::FrameDecodeError::Json(e)) => {
return Err(anyhow::anyhow!("frame JSON decode error: {e}"));
}
}
}
}
async fn write_frame(&mut self, envelope: &EventEnvelope) -> Result<()> {
let frame = encode(envelope);
self.writer.write_all(&frame).await?;
self.writer.flush().await?;
Ok(())
}
}
#[async_trait]
impl InterfaceSession for RawFramingSession {
async fn recv(&mut self) -> Option<InterfaceEvent> {
match &self.auth_state {
AuthState::Failed => return None,
AuthState::Authenticated(_) => {
let identity = match &self.auth_state {
AuthState::Authenticated(id) => id.clone(),
_ => unreachable!(),
};
let envelope = match self.read_frame().await {
Ok(e) => e,
Err(_) => return None,
};
return Some(InterfaceEvent::with_identity(envelope, identity));
}
AuthState::Pending => {}
}
let envelope = match self.read_frame().await {
Ok(e) => e,
Err(_) => {
self.auth_state = AuthState::Failed;
return None;
}
};
let token_raw = envelope.payload.as_str().unwrap_or("").as_bytes().to_vec();
let token = AuthToken { raw: token_raw };
match self.identity_provider.resolve_from_token(&token) {
Some(identity) => {
self.auth_state = AuthState::Authenticated(identity.clone());
Some(InterfaceEvent::with_identity(envelope, identity))
}
None => {
self.auth_state = AuthState::Failed;
None
}
}
}
async fn send(&mut self, envelope: EventEnvelope) -> Result<()> {
match self.auth_state {
AuthState::Failed => Err(anyhow::anyhow!("session authentication failed")),
_ => self.write_frame(&envelope).await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::ConfigIdentityProvider;
use crate::config::DynamicConfig;
use crate::interface::RawFramingConfig;
use arc_swap::ArcSwap;
use std::collections::HashMap;
fn make_provider() -> Arc<dyn IdentityProvider> {
Arc::new(ConfigIdentityProvider::new(Arc::new(ArcSwap::new(
Arc::new(DynamicConfig::default()),
))))
}
fn make_provider_with_identity(
identity: Identity,
valid_token: &str,
) -> (Arc<dyn IdentityProvider>, String) {
struct MockProvider {
identity: Identity,
valid_token: String,
}
impl IdentityProvider for MockProvider {
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
if token.raw == self.valid_token.as_bytes() {
Some(self.identity.clone())
} else {
None
}
}
}
let provider = Arc::new(MockProvider {
identity,
valid_token: valid_token.to_string(),
});
(provider, valid_token.to_string())
}
#[tokio::test]
async fn raw_framing_interface_accept_succeeds() {
let iface = RawFramingInterface;
let (_client, server) = tokio::io::duplex(1024);
let stream: Box<dyn TransportStream> = Box::new(server);
let config = StreamInterfaceConfig::RawFraming(RawFramingConfig {
auth: make_provider(),
});
let result = iface.accept(stream, &config).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn raw_framing_interface_rejects_ssh_config() {
let iface = RawFramingInterface;
let (_client, server) = tokio::io::duplex(1024);
let stream: Box<dyn TransportStream> = Box::new(server);
let config = StreamInterfaceConfig::Ssh(crate::interface::SshInterfaceConfig {
auth: make_provider(),
forwarding: Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default()))),
host_key: Arc::new(
russh::keys::PrivateKey::random(
&mut rand_core::OsRng,
russh::keys::Algorithm::Ed25519,
)
.unwrap(),
),
});
let result = iface.accept(stream, &config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn raw_framing_session_round_trip() {
let identity = Identity {
id: "test-id".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
let (provider, token_str) =
make_provider_with_identity(identity.clone(), "valid-test-token");
let (client, server) = tokio::io::duplex(4096);
let server_stream: Box<dyn TransportStream> = Box::new(server);
let client_stream: Box<dyn TransportStream> = Box::new(client);
let mut server_session = RawFramingSession::new(server_stream, provider);
let auth_envelope = EventEnvelope::new("auth", "auth-1", serde_json::json!(token_str));
let auth_frame = encode(&auth_envelope);
let mut client_writer = tokio::io::BufWriter::new(client_stream);
client_writer.write_all(&auth_frame).await.unwrap();
client_writer.flush().await.unwrap();
let event = server_session.recv().await;
assert!(event.is_some());
let event = event.unwrap();
assert!(event.identity.is_some());
assert_eq!(event.identity.as_ref().unwrap().id, "test-id");
let data_envelope =
EventEnvelope::call_requested("req-2", serde_json::json!({"op": "test"}));
let data_frame = encode(&data_envelope);
client_writer.write_all(&data_frame).await.unwrap();
client_writer.flush().await.unwrap();
let event = server_session.recv().await;
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.envelope.r#type, "call.requested");
assert_eq!(event.envelope.id, "req-2");
assert!(event.identity.is_some());
}
#[tokio::test]
async fn first_frame_auth_valid_token() {
let identity = Identity {
id: "auth-user".to_string(),
scopes: vec!["admin".to_string()],
resources: HashMap::new(),
};
let (provider, token_str) = make_provider_with_identity(identity, "my-valid-token");
let (client, server) = tokio::io::duplex(4096);
let server_stream: Box<dyn TransportStream> = Box::new(server);
let client_stream: Box<dyn TransportStream> = Box::new(client);
let mut session = RawFramingSession::new(server_stream, provider);
let auth_envelope = EventEnvelope::new("auth", "auth-1", serde_json::json!(token_str));
let frame = encode(&auth_envelope);
let mut writer = tokio::io::BufWriter::new(client_stream);
writer.write_all(&frame).await.unwrap();
writer.flush().await.unwrap();
let event = session.recv().await;
assert!(event.is_some());
let event = event.unwrap();
assert!(event.identity.is_some());
assert_eq!(event.identity.as_ref().unwrap().id, "auth-user");
assert_eq!(event.identity.as_ref().unwrap().scopes, vec!["admin"]);
}
#[tokio::test]
async fn first_frame_auth_invalid_token() {
let identity = Identity {
id: "auth-user".to_string(),
scopes: vec![],
resources: HashMap::new(),
};
let (provider, _) = make_provider_with_identity(identity, "correct-token");
let (client, server) = tokio::io::duplex(4096);
let server_stream: Box<dyn TransportStream> = Box::new(server);
let client_stream: Box<dyn TransportStream> = Box::new(client);
let mut session = RawFramingSession::new(server_stream, provider);
let bad_envelope =
EventEnvelope::new("auth", "auth-1", serde_json::json!("bad-token-value"));
let frame = encode(&bad_envelope);
let mut writer = tokio::io::BufWriter::new(client_stream);
writer.write_all(&frame).await.unwrap();
writer.flush().await.unwrap();
let event = session.recv().await;
assert!(event.is_none());
let data_envelope = EventEnvelope::call_requested("req-2", serde_json::json!({}));
let result = session.send(data_envelope).await;
assert!(result.is_err());
}
#[tokio::test]
async fn raw_framing_session_send() {
let identity = Identity {
id: "send-user".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
let (provider, token_str) = make_provider_with_identity(identity, "send-token");
let (client, server) = tokio::io::duplex(4096);
let server_stream: Box<dyn TransportStream> = Box::new(server);
let client_stream: Box<dyn TransportStream> = Box::new(client);
let mut server_session = RawFramingSession::new(server_stream, provider);
let auth_envelope = EventEnvelope::new("auth", "auth-1", serde_json::json!(token_str));
let auth_frame = encode(&auth_envelope);
let mut client_writer = tokio::io::BufWriter::new(client_stream);
client_writer.write_all(&auth_frame).await.unwrap();
client_writer.flush().await.unwrap();
let _ = server_session.recv().await;
let response = EventEnvelope::call_responded("req-1", serde_json::json!({"result": "ok"}));
let send_result = server_session.send(response).await;
assert!(send_result.is_ok());
}
#[tokio::test]
async fn raw_framing_multiple_frames_over_duplex() {
let identity = Identity {
id: "multi-user".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
let (provider, token_str) = make_provider_with_identity(identity, "multi-token");
let (client, server) = tokio::io::duplex(8192);
let server_stream: Box<dyn TransportStream> = Box::new(server);
let client_stream: Box<dyn TransportStream> = Box::new(client);
let mut session = RawFramingSession::new(server_stream, provider);
let mut client_writer = tokio::io::BufWriter::new(client_stream);
let auth_envelope = EventEnvelope::new("auth", "auth-0", serde_json::json!(token_str));
client_writer
.write_all(&encode(&auth_envelope))
.await
.unwrap();
for i in 1..=5 {
let envelope =
EventEnvelope::call_requested(format!("req-{i}"), serde_json::json!({"seq": i}));
client_writer.write_all(&encode(&envelope)).await.unwrap();
}
client_writer.flush().await.unwrap();
let auth_event = session.recv().await;
assert!(auth_event.is_some());
assert!(auth_event.unwrap().identity.is_some());
for i in 1..=5 {
let event = session.recv().await;
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.envelope.id, format!("req-{i}"));
assert!(event.identity.is_some());
}
}
#[test]
fn raw_framing_interface_type_exists() {
let _iface = RawFramingInterface;
}
}

View File

@@ -1,62 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
use crate::auth::Identity;
use crate::call::EventEnvelope;
#[derive(Debug, Clone)]
pub struct InterfaceEvent {
pub envelope: EventEnvelope,
pub identity: Option<Identity>,
}
impl InterfaceEvent {
pub fn new(envelope: EventEnvelope) -> Self {
Self {
envelope,
identity: None,
}
}
pub fn with_identity(envelope: EventEnvelope, identity: Identity) -> Self {
Self {
envelope,
identity: Some(identity),
}
}
}
#[async_trait]
pub trait InterfaceSession: Send {
async fn recv(&mut self) -> Option<InterfaceEvent>;
async fn send(&mut self, envelope: EventEnvelope) -> Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn interface_event_new() {
let envelope = EventEnvelope::call_requested("req-1", serde_json::json!({"op": "test"}));
let event = InterfaceEvent::new(envelope.clone());
assert_eq!(event.envelope, envelope);
assert!(event.identity.is_none());
}
#[test]
fn interface_event_with_identity() {
let envelope = EventEnvelope::call_requested("req-1", serde_json::json!({"op": "test"}));
let identity = Identity {
id: "SHA256:abc123".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
let event = InterfaceEvent::with_identity(envelope.clone(), identity.clone());
assert_eq!(event.envelope, envelope);
assert!(event.identity.is_some());
assert_eq!(event.identity.as_ref().unwrap().id, "SHA256:abc123");
}
}

View File

@@ -1,982 +0,0 @@
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use arc_swap::ArcSwap;
use async_trait::async_trait;
use russh::keys::ssh_key::HashAlg;
use russh::server::{self, Config};
use russh::Channel;
use russh::ChannelId;
use tokio::sync::mpsc;
use crate::auth::identity::{Identity, IdentityProvider};
use crate::call::frame::{FrameFramedReader, FrameFramedWriter};
use crate::call::EventEnvelope;
use crate::config::DynamicConfig;
use crate::interface::session::{InterfaceEvent, InterfaceSession};
use crate::interface::{StreamInterface, StreamInterfaceConfig, TransportStream};
use crate::server::control_channel::{
ControlChannelHandler, ControlChannelRouter, DuplexStream, ALKNET_CONTROL_DESTINATION,
ALKNET_PREFIX,
};
use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
use crate::transport::TransportKind;
struct SshHandler {
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
outbound_proxy: Option<crate::server::handler::ProxyConfig>,
remote_addr: Option<SocketAddr>,
transport: TransportKind,
connection_limiter: Arc<ConnectionRateLimiter>,
connection_allowed: bool,
auth_limiter: AuthAttemptLimiter,
authenticated_identity: Option<Identity>,
control_channel_router: ControlChannelRouter,
bridge_event_tx: Option<mpsc::Sender<InterfaceEvent>>,
bridge_envelope_rx: Option<mpsc::Receiver<EventEnvelope>>,
connected_at: Instant,
}
impl SshHandler {
fn new(
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
outbound_proxy: Option<crate::server::handler::ProxyConfig>,
remote_addr: Option<SocketAddr>,
transport: TransportKind,
connection_limiter: Arc<ConnectionRateLimiter>,
max_auth_attempts: usize,
) -> Self {
let allowed = if let Some(addr) = remote_addr {
let ip = addr.ip();
if connection_limiter.check(ip) {
connection_limiter.on_connect(ip);
tracing::info!(
remote_addr = %addr,
transport = %transport,
"connection opened"
);
true
} else {
tracing::info!(
remote_addr = %addr,
transport = %transport,
"connection rejected"
);
false
}
} else {
true
};
Self {
dynamic,
identity_provider,
outbound_proxy,
remote_addr,
transport,
connection_limiter,
connection_allowed: allowed,
auth_limiter: AuthAttemptLimiter::new(max_auth_attempts),
authenticated_identity: None,
control_channel_router: ControlChannelRouter::without_handler(),
bridge_event_tx: None,
bridge_envelope_rx: None,
connected_at: Instant::now(),
}
}
#[allow(dead_code)]
fn with_control_channel_router(mut self, router: ControlChannelRouter) -> Self {
self.control_channel_router = router;
self
}
fn with_bridge_channels(
mut self,
event_tx: mpsc::Sender<InterfaceEvent>,
envelope_rx: mpsc::Receiver<EventEnvelope>,
) -> Self {
self.bridge_event_tx = Some(event_tx);
self.bridge_envelope_rx = Some(envelope_rx);
self
}
fn has_control_channel_bridge(&self) -> bool {
self.bridge_event_tx.is_some() && self.bridge_envelope_rx.is_some()
}
}
impl Drop for SshHandler {
fn drop(&mut self) {
if let Some(addr) = self.remote_addr {
if self.connection_allowed {
self.connection_limiter.on_disconnect(addr.ip());
let duration = self.connected_at.elapsed();
tracing::info!(
remote_addr = %addr,
duration_secs = duration.as_secs_f64(),
"connection closed"
);
}
}
}
}
#[async_trait]
impl server::Handler for SshHandler {
type Error = russh::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<server::Auth, Self::Error> {
if !self.auth_limiter.check() {
let remote_addr_display = self
.remote_addr
.map_or("unknown".to_string(), |a| a.to_string());
let fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256));
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "reject",
"auth attempt"
);
return Ok(server::Auth::Reject {
proceed_with_methods: None,
});
}
let fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256));
let remote_addr_display = self
.remote_addr
.map_or("unknown".to_string(), |a| a.to_string());
let identity = self
.identity_provider
.resolve_from_fingerprint(&fingerprint);
match identity {
Some(id) => {
self.authenticated_identity = Some(id);
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "accept",
"auth attempt"
);
Ok(server::Auth::Accept)
}
None => {
self.auth_limiter.on_failure();
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "reject",
"auth attempt"
);
Ok(server::Auth::Reject {
proceed_with_methods: None,
})
}
}
}
async fn channel_open_direct_tcpip(
&mut self,
channel: Channel<server::Msg>,
host_to_connect: &str,
port_to_connect: u32,
originator_address: &str,
originator_port: u32,
_session: &mut server::Session,
) -> Result<bool, Self::Error> {
if host_to_connect.starts_with(ALKNET_PREFIX) {
if host_to_connect == ALKNET_CONTROL_DESTINATION && self.has_control_channel_bridge() {
let event_tx = self.bridge_event_tx.take().unwrap();
let envelope_rx = self.bridge_envelope_rx.take().unwrap();
let identity = self.authenticated_identity.clone();
tokio::spawn(async move {
let stream = channel.into_stream();
let (read_half, write_half) = tokio::io::split(stream);
run_control_channel_bridge(
read_half,
write_half,
identity,
event_tx,
envelope_rx,
)
.await;
});
let _ = (originator_address, originator_port);
return Ok(true);
}
if self.control_channel_router.has_handler() {
if let Some(handler) = self.control_channel_router.take_handler() {
let stream: Box<dyn DuplexStream> = Box::new(channel.into_stream());
tokio::spawn(async move {
handler.handle_channel(stream).await;
});
}
let _ = (originator_address, originator_port);
return Ok(true);
}
return Ok(false);
}
let identity = self
.authenticated_identity
.clone()
.unwrap_or_else(|| Identity {
id: String::new(),
scopes: vec![],
resources: std::collections::HashMap::new(),
});
let policy = self.dynamic.load();
let allowed = policy.forwarding.check(
host_to_connect,
port_to_connect as u16,
&identity,
self.transport.clone(),
);
if !allowed {
tracing::info!(
remote_addr = ?self.remote_addr,
target = %format!("{host_to_connect}:{port_to_connect}"),
identity = %identity.id,
transport = %self.transport,
"forwarding denied by policy"
);
return Ok(false);
}
let target_host = host_to_connect.to_string();
let target_port = port_to_connect;
let proxy_config =
self.outbound_proxy
.clone()
.unwrap_or(crate::server::handler::ProxyConfig {
mode: crate::server::handler::ProxyMode::Direct,
});
tokio::spawn(async move {
let target = match format!("{target_host}:{target_port}")
.parse::<std::net::SocketAddr>()
{
Ok(addr) => addr,
Err(_) => {
match tokio::net::lookup_host((&target_host[..], target_port as u16)).await {
Ok(mut addrs) => match addrs.next() {
Some(addr) => addr,
None => return,
},
Err(_) => return,
}
}
};
crate::server::channel_proxy::proxy_channel(
channel.into_stream(),
target,
&proxy_config,
)
.await;
});
let _ = (originator_address, originator_port);
Ok(true)
}
async fn channel_open_session(
&mut self,
_channel: Channel<server::Msg>,
session: &mut server::Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
"rejected session channel (shell/exec not supported)"
);
let _ = session;
Ok(false)
}
async fn channel_open_x11(
&mut self,
_channel: Channel<server::Msg>,
_originator_address: &str,
_originator_port: u32,
session: &mut server::Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
"rejected x11 channel"
);
let _ = session;
Ok(false)
}
async fn channel_open_forwarded_tcpip(
&mut self,
_channel: Channel<server::Msg>,
host_to_connect: &str,
port_to_connect: u32,
_originator_address: &str,
_originator_port: u32,
session: &mut server::Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
target = %format!("{host_to_connect}:{port_to_connect}"),
"rejected forwarded-tcpip channel (remote port forwarding not supported)"
);
let _ = session;
Ok(false)
}
async fn exec_request(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut server::Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
data_len = data.len(),
"rejected exec request on channel (shell/exec not supported)"
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
"rejected shell request on channel"
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn subsystem_request(
&mut self,
channel: ChannelId,
name: &str,
session: &mut server::Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
subsystem = name,
"rejected subsystem request on channel"
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn pty_request(
&mut self,
channel: ChannelId,
term: &str,
col_width: u32,
row_height: u32,
pix_width: u32,
pix_height: u32,
modes: &[(russh::Pty, u32)],
session: &mut server::Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
term = term,
"rejected pty request on channel"
);
let _ = (col_width, row_height, pix_width, pix_height, modes);
let _ = session.channel_failure(channel);
Ok(())
}
async fn env_request(
&mut self,
channel: ChannelId,
variable_name: &str,
variable_value: &str,
session: &mut server::Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
variable = variable_name,
"rejected env request on channel"
);
let _ = variable_value;
let _ = session.channel_failure(channel);
Ok(())
}
async fn x11_request(
&mut self,
channel: ChannelId,
single_connection: bool,
x11_auth_protocol: &str,
x11_auth_cookie: &str,
x11_screen_number: u32,
session: &mut server::Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
"rejected x11 request on channel"
);
let _ = (
single_connection,
x11_auth_protocol,
x11_auth_cookie,
x11_screen_number,
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn agent_request(
&mut self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
"rejected agent forwarding request on channel"
);
let _ = session;
Ok(false)
}
async fn tcpip_forward(
&mut self,
address: &str,
port: &mut u32,
session: &mut server::Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
address = address,
port = *port,
"rejected tcpip-forward request (remote port forwarding not supported)"
);
let _ = session;
Ok(false)
}
async fn cancel_tcpip_forward(
&mut self,
address: &str,
port: u32,
session: &mut server::Session,
) -> Result<bool, Self::Error> {
let _ = (address, port, session);
Ok(false)
}
async fn streamlocal_forward(
&mut self,
socket_path: &str,
session: &mut server::Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
socket_path = socket_path,
"rejected streamlocal-forward request"
);
let _ = session;
Ok(false)
}
async fn signal(
&mut self,
channel: ChannelId,
signal: russh::Sig,
session: &mut server::Session,
) -> Result<(), Self::Error> {
tracing::debug!(
remote_addr = ?self.remote_addr,
channel = %channel,
signal = ?signal,
"received signal on channel (ignored)"
);
let _ = session;
Ok(())
}
}
pub struct SshInterface {
config: Arc<Config>,
dynamic: Arc<ArcSwap<DynamicConfig>>,
connection_limiter: Arc<ConnectionRateLimiter>,
outbound_proxy: Option<crate::server::handler::ProxyConfig>,
max_auth_attempts: usize,
}
impl SshInterface {
pub fn new(config: Arc<Config>, dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
Self {
config,
dynamic,
connection_limiter: Arc::new(ConnectionRateLimiter::new(0)),
outbound_proxy: None,
max_auth_attempts: 10,
}
}
pub fn with_connection_limiter(mut self, limiter: Arc<ConnectionRateLimiter>) -> Self {
self.connection_limiter = limiter;
self
}
pub fn with_outbound_proxy(
mut self,
proxy: Option<crate::server::handler::ProxyConfig>,
) -> Self {
self.outbound_proxy = proxy;
self
}
pub fn with_max_auth_attempts(mut self, max: usize) -> Self {
self.max_auth_attempts = max;
self
}
pub fn config(&self) -> &Arc<Config> {
&self.config
}
pub fn dynamic(&self) -> &Arc<ArcSwap<DynamicConfig>> {
&self.dynamic
}
async fn accept_inner(
&self,
stream: Box<dyn TransportStream>,
ssh_config: &crate::interface::SshInterfaceConfig,
remote_addr: Option<SocketAddr>,
transport: TransportKind,
) -> Result<SshSession> {
let identity_provider = Arc::clone(&ssh_config.auth);
let _forwarding = Arc::clone(&ssh_config.forwarding);
let (event_tx, event_rx) = mpsc::channel::<InterfaceEvent>(256);
let (envelope_tx, envelope_rx) = mpsc::channel::<EventEnvelope>(256);
let handler = SshHandler::new(
Arc::clone(&self.dynamic),
identity_provider,
self.outbound_proxy.clone(),
remote_addr,
transport,
Arc::clone(&self.connection_limiter),
self.max_auth_attempts,
)
.with_bridge_channels(event_tx, envelope_rx);
let running = server::run_stream(Arc::clone(&self.config), stream, handler).await?;
let handle = running.handle();
let join = tokio::spawn(async {
let _ = running.await;
});
Ok(SshSession {
handle,
_join: join,
event_rx,
envelope_tx,
})
}
}
#[async_trait]
impl StreamInterface for SshInterface {
type Session = SshSession;
async fn accept(
&self,
stream: Box<dyn TransportStream>,
config: &StreamInterfaceConfig,
) -> Result<Self::Session> {
let ssh_config = match config {
StreamInterfaceConfig::Ssh(c) => c,
StreamInterfaceConfig::RawFraming(_) => {
return Err(anyhow::anyhow!("SshInterface received RawFramingConfig"));
}
};
self.accept_inner(stream, ssh_config, None, TransportKind::Tcp)
.await
}
}
pub struct SshSession {
handle: server::Handle,
_join: tokio::task::JoinHandle<()>,
event_rx: mpsc::Receiver<InterfaceEvent>,
envelope_tx: mpsc::Sender<EventEnvelope>,
}
impl SshSession {
pub fn handle(&self) -> &server::Handle {
&self.handle
}
}
#[async_trait]
impl InterfaceSession for SshSession {
async fn recv(&mut self) -> Option<InterfaceEvent> {
self.event_rx.recv().await
}
async fn send(&mut self, envelope: EventEnvelope) -> Result<()> {
self.envelope_tx
.send(envelope)
.await
.map_err(|_| anyhow::anyhow!("control channel bridge closed"))
}
}
async fn run_control_channel_bridge<R, W>(
read_half: R,
write_half: W,
identity: Option<Identity>,
event_tx: mpsc::Sender<InterfaceEvent>,
mut envelope_rx: mpsc::Receiver<EventEnvelope>,
) where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let mut reader = FrameFramedReader::new(read_half);
let mut writer = FrameFramedWriter::new(write_half);
loop {
tokio::select! {
frame = reader.read_frame() => {
match frame {
Ok(Some(envelope)) => {
let event = match &identity {
Some(id) => InterfaceEvent::with_identity(envelope, id.clone()),
None => InterfaceEvent::new(envelope),
};
if event_tx.send(event).await.is_err() {
return;
}
}
Ok(None) => return,
Err(_) => return,
}
}
envelope = envelope_rx.recv() => {
match envelope {
Some(envelope) => {
if writer.write_frame(&envelope).await.is_err() {
return;
}
}
None => return,
}
}
}
}
}
pub struct ControlChannelBridge {
identity: Option<Identity>,
}
impl ControlChannelBridge {
pub fn new(identity: Option<Identity>) -> Self {
Self { identity }
}
}
#[async_trait]
impl ControlChannelHandler for ControlChannelBridge {
async fn handle_channel(&self, stream: Box<dyn DuplexStream>) {
let (event_tx, _event_rx) = mpsc::channel::<InterfaceEvent>(256);
let (_envelope_tx, envelope_rx) = mpsc::channel::<EventEnvelope>(256);
let identity = self.identity.clone();
let (read_half, write_half) = tokio::io::split(stream);
tokio::spawn(run_control_channel_bridge(
read_half,
write_half,
identity,
event_tx,
envelope_rx,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::call::frame::{FrameFramedReader, FrameFramedWriter};
use tokio::io::duplex;
#[test]
fn ssh_interface_constructs_with_config() {
let config = Arc::new(Config {
keys: vec![russh::keys::PrivateKey::random(
&mut rand_core::OsRng,
russh::keys::Algorithm::Ed25519,
)
.unwrap()],
..Default::default()
});
let dynamic = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let iface = SshInterface::new(config, dynamic);
assert!(iface.config().keys.len() >= 1);
}
#[test]
fn ssh_interface_builder_pattern() {
let config = Arc::new(Config {
keys: vec![russh::keys::PrivateKey::random(
&mut rand_core::OsRng,
russh::keys::Algorithm::Ed25519,
)
.unwrap()],
..Default::default()
});
let dynamic = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let limiter = Arc::new(ConnectionRateLimiter::new(5));
let iface = SshInterface::new(config, dynamic)
.with_connection_limiter(limiter)
.with_max_auth_attempts(3);
assert!(iface.config().keys.len() >= 1);
}
#[test]
fn ssh_handler_auth_delegates_to_identity_provider() {
use std::collections::HashMap;
struct MockProvider {
identities: HashMap<String, Identity>,
}
impl IdentityProvider for MockProvider {
fn resolve_from_fingerprint(&self, fp: &str) -> Option<Identity> {
self.identities.get(fp).cloned()
}
fn resolve_from_token(&self, _t: &crate::auth::AuthToken) -> Option<Identity> {
None
}
}
let mut ids = HashMap::new();
ids.insert(
"SHA256:testkey".to_string(),
Identity {
id: "SHA256:testkey".to_string(),
scopes: vec!["admin".to_string()],
resources: HashMap::new(),
},
);
let provider: Arc<dyn IdentityProvider> = Arc::new(MockProvider { identities: ids });
let dynamic = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let limiter = Arc::new(ConnectionRateLimiter::new(0));
let handler = SshHandler::new(
dynamic,
provider,
None,
None,
TransportKind::Tcp,
limiter,
10,
);
assert!(handler.authenticated_identity.is_none());
}
#[test]
fn ssh_handler_connection_rate_limiting() {
let dynamic = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let provider: Arc<dyn IdentityProvider> = Arc::new(
crate::auth::identity::ConfigIdentityProvider::new(Arc::clone(&dynamic)),
);
let limiter = Arc::new(ConnectionRateLimiter::new(1));
let addr: SocketAddr = "10.0.0.1:22".parse().unwrap();
let h1 = SshHandler::new(
Arc::clone(&dynamic),
Arc::clone(&provider),
None,
Some(addr),
TransportKind::Tcp,
Arc::clone(&limiter),
10,
);
assert!(h1.connection_allowed);
let h2 = SshHandler::new(
dynamic,
provider,
None,
Some(addr),
TransportKind::Tcp,
limiter,
10,
);
assert!(!h2.connection_allowed);
}
#[tokio::test]
async fn ssh_interface_rejects_raw_framing_config() {
let config = Arc::new(Config {
keys: vec![russh::keys::PrivateKey::random(
&mut rand_core::OsRng,
russh::keys::Algorithm::Ed25519,
)
.unwrap()],
..Default::default()
});
let dynamic = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
let iface = SshInterface::new(config, dynamic);
let (_client, server) = tokio::io::duplex(1024);
let stream: Box<dyn TransportStream> = Box::new(server);
let raw_config = StreamInterfaceConfig::RawFraming(crate::interface::RawFramingConfig {
auth: Arc::new(crate::auth::ConfigIdentityProvider::new(Arc::new(
ArcSwap::new(Arc::new(DynamicConfig::default())),
))),
});
let result = iface.accept(stream, &raw_config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn ssh_session_round_trip_event_envelope() {
let (client, server) = duplex(4096);
let (event_tx, mut event_rx) = mpsc::channel::<InterfaceEvent>(256);
let (envelope_tx, envelope_rx) = mpsc::channel::<EventEnvelope>(256);
let identity = Identity {
id: "SHA256:test".to_string(),
scopes: vec![],
resources: std::collections::HashMap::new(),
};
let identity_clone = identity.clone();
let (server_read, server_write) = tokio::io::split(server);
tokio::spawn(run_control_channel_bridge(
server_read,
server_write,
Some(identity_clone),
event_tx,
envelope_rx,
));
let (client_read, client_write) = tokio::io::split(client);
let mut client_reader = FrameFramedReader::new(client_read);
let mut client_writer = FrameFramedWriter::new(client_write);
let envelope = EventEnvelope::call_requested("req-1", serde_json::json!({"op": "test"}));
client_writer.write_frame(&envelope).await.unwrap();
let received_event =
tokio::time::timeout(std::time::Duration::from_secs(2), event_rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(received_event.envelope, envelope);
assert_eq!(received_event.identity.as_ref().unwrap().id, "SHA256:test");
let response = EventEnvelope::call_responded("req-1", serde_json::json!({"result": 42}));
envelope_tx.send(response.clone()).await.unwrap();
let read_back = tokio::time::timeout(
std::time::Duration::from_secs(2),
client_reader.read_frame(),
)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(read_back, response);
}
#[tokio::test]
async fn ssh_session_recv_without_identity() {
let (client, server) = duplex(4096);
let (event_tx, mut event_rx) = mpsc::channel::<InterfaceEvent>(256);
let (_envelope_tx, envelope_rx) = mpsc::channel::<EventEnvelope>(256);
let (server_read, server_write) = tokio::io::split(server);
tokio::spawn(run_control_channel_bridge(
server_read,
server_write,
None,
event_tx,
envelope_rx,
));
let (client_read, client_write) = tokio::io::split(client);
let mut client_writer = FrameFramedWriter::new(client_write);
let _client_reader = FrameFramedReader::new(client_read);
let envelope = EventEnvelope::call_requested("req-2", serde_json::json!({"op": "no-id"}));
client_writer.write_frame(&envelope).await.unwrap();
let received_event =
tokio::time::timeout(std::time::Duration::from_secs(2), event_rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(received_event.envelope, envelope);
assert!(received_event.identity.is_none());
}
#[tokio::test]
async fn control_channel_router_with_handler_routes_data() {
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let called_clone = called.clone();
struct TrackingHandler {
called: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
#[async_trait]
impl ControlChannelHandler for TrackingHandler {
async fn handle_channel(&self, _stream: Box<dyn DuplexStream>) {
self.called.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let router = ControlChannelRouter::with_handler(Box::new(TrackingHandler {
called: called_clone,
}));
assert!(router.has_handler());
let (_client, server) = duplex(64);
let stream: Box<dyn DuplexStream> = Box::new(server);
let result = router.route(stream).await;
assert!(result.is_ok());
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
}
}

View File

@@ -1,110 +1,17 @@
//! # alknet-core
//! alknet-core: Core library for ALPN-based protocol dispatch.
//!
//! Core library for [Alknet](https://git.alk.dev/alkdev/alknet), a self-hostable SSH-based
//! tunnel tool. This crate provides the transport abstraction, SOCKS5 server, port forwarding,
//! authentication, and server handler — everything needed to build an alknet client or server
//! on top of pluggable transports.
//!
//! > **Alpha software.** This crate depends on solid libraries (russh, tokio, rustls, iroh)
//! > for core functionality, but the integration layer has not been battle-tested. Use with
//! > caution and report issues.
//!
//! # Key concepts
//!
//! - **Transport trait** — produces a duplex byte stream (`AsyncRead + AsyncWrite + Unpin + Send`)
//! that SSH consumes. Implementations: TCP, TLS, iroh (QUIC P2P).
//! - **SOCKS5 server** — the primary client interface, listening on a local port and routing
//! traffic through SSH channels.
//! - **Port forwarding** — `-L` local and `-R` remote port forwards over SSH channels.
//! - **Authentication** — Ed25519 public key and OpenSSH certificate authority. No passwords.
//! - **Server handler** — accepts SSH connections via a `TransportAcceptor` and proxies
//! `direct-tcpip` channel requests to targets (directly or via outbound proxy).
//!
//! # Feature flags
//!
//! | Feature | Default | Description |
//! |---------|---------|-------------|
//! | `tls` | yes | TLS transport via `tokio-rustls` |
//! | `iroh` | yes | iroh QUIC P2P transport |
//! | `acme` | no | ACME/Let's Encrypt auto-cert provisioning (implies `tls`) |
//! | `irpc` | no | irpc service layer (AuthProtocol, AuthServiceImpl) |
//! | `testutil` | no | Test utilities (for internal use) |
//!
//! # Quick example
//!
//! ```no_run
//! use std::sync::Arc;
//! use alknet_core::transport::TcpTransport;
//! use alknet_core::client::{ClientSession, ConnectOptions, TransportMode};
//! use alknet_core::auth::keys::KeySource;
//! use alknet_core::Transport;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//! let opts = ConnectOptions::new(KeySource::File("/path/to/key".into()))
//! .server("example.com:22")
//! .transport_mode(TransportMode::Tcp);
//! let transport = Arc::new(TcpTransport::new("example.com:22".parse()?));
//! let session = ClientSession::new(opts, transport).await?;
//! session.run().await?;
//! Ok(())
//! }
//! ```
//! Every handler crate depends on this crate. It provides the
//! [`ProtocolHandler`][crate::types::ProtocolHandler] trait, the
//! [`Connection`][crate::types::Connection] wrapper, auth primitives,
//! hot-reloadable configuration, and the [`AlknetEndpoint`][crate::endpoint::AlknetEndpoint]
//! that dispatches incoming QUIC connections by ALPN string.
pub mod auth;
pub mod call;
pub mod client;
pub mod config;
pub mod credentials;
pub mod error;
pub mod interface;
pub mod server;
pub mod socks5;
pub mod transport;
pub mod endpoint;
pub mod fingerprint;
pub mod store;
pub mod types;
#[cfg(feature = "http")]
pub mod http;
#[cfg(feature = "http")]
pub use http::IdentityExt;
#[cfg(feature = "testutil")]
pub mod testutil;
#[cfg(feature = "irpc")]
pub use auth::{AuthProtocol, AuthResult, AuthServiceImpl};
pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
pub use call::{
decode as decode_frame, decode_with_remainder as decode_frame_with_remainder,
encode as encode_frame,
};
pub use call::{
register_default_operations, services_list_spec, services_schema_spec, AccessControl,
CallError, EventEnvelope, FrameDecodeError, Handler, OperationContext, OperationEnv,
OperationRegistry, OperationRegistryBuilder, OperationSpec, OperationType, PendingRequestMap,
ResponseEnvelope,
};
pub use call::{CALL_ABORTED, CALL_COMPLETED, CALL_ERROR, CALL_REQUESTED, CALL_RESPONDED};
pub use client::channel_manager::{ChannelManager, ForwardRequest};
pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
pub use config::{
AuthPolicy, ConfigReloadHandle, ConfigServiceImpl, DynamicConfig, ForwardingAction,
ForwardingPolicy, ForwardingRule, RateLimitConfig, StaticConfig, TargetPattern,
};
pub use credentials::{
ConfigCredentialProvider, CredentialProvider, CredentialSet, SecretStoreCredentialProvider,
};
pub use error::{AuthError, ChannelError, ConfigError, ForwardError, TransportError};
pub use interface::{
is_valid_pair, DnsInterface, DnsInterfaceConfig, HttpInterface, HttpInterfaceConfig,
InterfaceConfig, InterfaceEvent, InterfaceRequest, InterfaceResponse, InterfaceSession,
MessageInterface, MessageInterfaceConfig, MessageInterfaceKind, RawFramingConfig,
RawFramingInterface, RawFramingSession, SshInterface, SshInterfaceConfig, SshSession,
StreamInterface, StreamInterfaceConfig, StreamInterfaceKind, TransportKindBase,
TransportStream, VALID_TRANSPORT_INTERFACE_PAIRS,
};
pub use server::serve::{
DnsListenerConfig, HttpListenerConfig, ListenerConfig, ServeError, ServeOptions,
ServeTransportMode, Server, StreamListenerConfig,
};
pub use transport::{Transport, TransportAcceptor, TransportInfo, TransportKind};
pub use auth::{IdentityProvider, IdentityStore};
pub use store::{CredentialStore, EncryptedData, InMemoryCredentialStore, StoreError};

View File

@@ -1,555 +0,0 @@
//! Outbound connection proxy for SSH channel targets.
//!
//! Connects to the requested `host:port` either directly, via SOCKS5 proxy, or
//! via HTTP CONNECT proxy, then proxies bytes bidirectionally between the SSH
//! channel and the outbound TCP stream.
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use super::handler::{ProxyConfig, ProxyMode};
#[derive(Debug, thiserror::Error)]
pub enum ChannelProxyError {
#[error("connection refused")]
ConnectionRefused,
#[error("target unreachable")]
TargetUnreachable,
#[error("socks5 proxy handshake failed")]
Socks5HandshakeFailed,
#[error("socks5 proxy rejected connection")]
Socks5ProxyRejected,
#[error("http connect proxy handshake failed")]
HttpConnectHandshakeFailed,
#[error("http connect proxy rejected: {0}")]
HttpConnectProxyRejected(String),
#[error("io error")]
Io(#[from] std::io::Error),
}
pub async fn connect_outbound(
target: SocketAddr,
proxy: &ProxyConfig,
) -> Result<TcpStream, ChannelProxyError> {
match &proxy.mode {
ProxyMode::Direct => connect_direct(target).await,
ProxyMode::Socks5(addr) => connect_socks5(target, *addr).await,
ProxyMode::HttpConnect(addr) => connect_http_connect(target, *addr).await,
}
}
async fn connect_direct(target: SocketAddr) -> Result<TcpStream, ChannelProxyError> {
TcpStream::connect(target)
.await
.map_err(|e| map_connection_error(e, target))
}
async fn connect_socks5(
target: SocketAddr,
proxy_addr: SocketAddr,
) -> Result<TcpStream, ChannelProxyError> {
let mut stream = TcpStream::connect(proxy_addr)
.await
.map_err(ChannelProxyError::from)?;
stream.write_all(&[0x05, 0x01, 0x00]).await?;
stream.flush().await?;
let mut resp = [0u8; 2];
stream.read_exact(&mut resp).await?;
if resp[0] != 0x05 || resp[1] != 0x00 {
return Err(ChannelProxyError::Socks5HandshakeFailed);
}
let ip_bytes = target.ip().to_string();
let mut connect_req = vec![0x05, 0x01, 0x00, 0x03];
connect_req.push(ip_bytes.len() as u8);
connect_req.extend_from_slice(ip_bytes.as_bytes());
connect_req.extend_from_slice(&target.port().to_be_bytes());
stream.write_all(&connect_req).await?;
stream.flush().await?;
let mut reply_header = [0u8; 4];
stream.read_exact(&mut reply_header).await?;
if reply_header[0] != 0x05 {
return Err(ChannelProxyError::Socks5HandshakeFailed);
}
if reply_header[1] != 0x00 {
return Err(ChannelProxyError::Socks5ProxyRejected);
}
let atyp = reply_header[3];
match atyp {
0x01 => {
let mut _addr = [0u8; 4];
stream.read_exact(&mut _addr).await?;
}
0x04 => {
let mut _addr = [0u8; 16];
stream.read_exact(&mut _addr).await?;
}
0x03 => {
let len = stream.read_u8().await?;
let mut _domain = vec![0u8; len as usize];
stream.read_exact(&mut _domain).await?;
}
_ => {
return Err(ChannelProxyError::Socks5HandshakeFailed);
}
}
let mut _port = [0u8; 2];
stream.read_exact(&mut _port).await?;
Ok(stream)
}
async fn connect_http_connect(
target: SocketAddr,
proxy_addr: SocketAddr,
) -> Result<TcpStream, ChannelProxyError> {
let mut stream = TcpStream::connect(proxy_addr)
.await
.map_err(ChannelProxyError::from)?;
let connect_request = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n\r\n",
target.ip(),
target.port(),
target.ip(),
target.port()
);
stream.write_all(connect_request.as_bytes()).await?;
stream.flush().await?;
let mut response = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = stream.read(&mut buf).await?;
if n == 0 {
return Err(ChannelProxyError::HttpConnectHandshakeFailed);
}
response.extend_from_slice(&buf[..n]);
if response.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response_str = String::from_utf8_lossy(&response);
let status_line = response_str.lines().next().unwrap_or("");
if status_line.contains("200") {
Ok(stream)
} else {
Err(ChannelProxyError::HttpConnectProxyRejected(
status_line.to_string(),
))
}
}
fn map_connection_error(e: std::io::Error, _target: SocketAddr) -> ChannelProxyError {
match e.kind() {
std::io::ErrorKind::ConnectionRefused => ChannelProxyError::ConnectionRefused,
std::io::ErrorKind::AddrNotAvailable
| std::io::ErrorKind::NetworkUnreachable
| std::io::ErrorKind::HostUnreachable => ChannelProxyError::TargetUnreachable,
_ => ChannelProxyError::Io(e),
}
}
pub async fn proxy_channel<S>(channel: S, target: SocketAddr, proxy: &ProxyConfig)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
if let Ok(outbound) = connect_outbound(target, proxy).await {
let (mut read_chan, mut write_chan) = tokio::io::split(channel);
let (mut read_out, mut write_out) = outbound.into_split();
let client_to_target = tokio::spawn(async move {
let _ = tokio::io::copy(&mut read_chan, &mut write_out).await;
let _ = write_out.shutdown().await;
});
let target_to_client = tokio::spawn(async move {
let _ = tokio::io::copy(&mut read_out, &mut write_chan).await;
let _ = write_chan.shutdown().await;
});
let _ = client_to_target.await;
let _ = target_to_client.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt, DuplexStream};
use tokio::net::TcpListener;
fn direct_config() -> ProxyConfig {
ProxyConfig {
mode: ProxyMode::Direct,
}
}
fn socks5_config(addr: SocketAddr) -> ProxyConfig {
ProxyConfig {
mode: ProxyMode::Socks5(addr),
}
}
fn http_connect_config(addr: SocketAddr) -> ProxyConfig {
ProxyConfig {
mode: ProxyMode::HttpConnect(addr),
}
}
#[tokio::test]
async fn direct_connection_to_echo_server() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let stream = connect_outbound(addr, &direct_config()).await.unwrap();
let (mut read, mut write) = stream.into_split();
write.write_all(b"hello").await.unwrap();
let mut buf = [0u8; 5];
read.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
let _ = server.await;
}
#[tokio::test]
async fn direct_connection_target_unreachable() {
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
let result = connect_outbound(target, &direct_config()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn socks5_proxy_handshake() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_addr = target_listener.local_addr().unwrap();
let target_server = tokio::spawn(async move {
let (mut sock, _) = target_listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut greeting = [0u8; 3];
proxy_sock.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting[0], 0x05);
proxy_sock.write_all(&[0x05, 0x00]).await.unwrap();
let mut req_header = [0u8; 4];
proxy_sock.read_exact(&mut req_header).await.unwrap();
assert_eq!(req_header[0], 0x05);
assert_eq!(req_header[1], 0x01);
let atyp = req_header[3];
assert_eq!(atyp, 0x03);
let domain_len = proxy_sock.read_u8().await.unwrap() as usize;
let mut domain = vec![0u8; domain_len];
proxy_sock.read_exact(&mut domain).await.unwrap();
let mut port_bytes = [0u8; 2];
proxy_sock.read_exact(&mut port_bytes).await.unwrap();
let target: SocketAddr = format!(
"{}:{}",
String::from_utf8_lossy(&domain),
u16::from_be_bytes(port_bytes)
)
.parse()
.unwrap();
let reply = vec![0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0];
proxy_sock.write_all(&reply).await.unwrap();
let mut target_stream = TcpStream::connect(target).await.unwrap();
let _ = tokio::io::copy_bidirectional(&mut proxy_sock, &mut target_stream).await;
});
let config = socks5_config(proxy_addr);
let mut stream = connect_outbound(target_addr, &config).await.unwrap();
stream.write_all(b"hello socks").await.unwrap();
let mut buf = [0u8; 11];
stream.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello socks");
drop(stream);
let _ = target_server.await;
let _ = proxy_server.await;
}
#[tokio::test]
async fn socks5_proxy_rejected() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut greeting = [0u8; 3];
proxy_sock.read_exact(&mut greeting).await.unwrap();
proxy_sock.write_all(&[0x05, 0x00]).await.unwrap();
let mut req_header = [0u8; 4];
proxy_sock.read_exact(&mut req_header).await.unwrap();
let domain_len = proxy_sock.read_u8().await.unwrap() as usize;
let mut domain = vec![0u8; domain_len];
proxy_sock.read_exact(&mut domain).await.unwrap();
let mut port_bytes = [0u8; 2];
proxy_sock.read_exact(&mut port_bytes).await.unwrap();
let reply = vec![0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0];
proxy_sock.write_all(&reply).await.unwrap();
});
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let config = socks5_config(proxy_addr);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ChannelProxyError::Socks5ProxyRejected
));
let _ = proxy_server.await;
}
#[tokio::test]
async fn http_connect_proxy_handshake() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_addr = target_listener.local_addr().unwrap();
let target_server = tokio::spawn(async move {
let (mut sock, _) = target_listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = proxy_sock.read(&mut buf).await.unwrap();
request.extend_from_slice(&buf[..n]);
if request.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response = "HTTP/1.1 200 Connection Established\r\n\r\n";
proxy_sock.write_all(response.as_bytes()).await.unwrap();
let target_str = extract_connect_target(&String::from_utf8_lossy(&request));
let mut target_stream = TcpStream::connect(target_str).await.unwrap();
let _ = tokio::io::copy_bidirectional(&mut proxy_sock, &mut target_stream).await;
});
let config = http_connect_config(proxy_addr);
let mut stream = connect_outbound(target_addr, &config).await.unwrap();
stream.write_all(b"hello http").await.unwrap();
let mut buf = [0u8; 10];
stream.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello http");
drop(stream);
let _ = target_server.await;
let _ = proxy_server.await;
}
fn extract_connect_target(request: &str) -> String {
let connect_line = request.lines().next().unwrap_or("");
let parts: Vec<&str> = connect_line.split_whitespace().collect();
if parts.len() >= 2 {
parts[1].to_string()
} else {
String::new()
}
}
#[tokio::test]
async fn http_connect_proxy_rejected() {
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy_addr = proxy_listener.local_addr().unwrap();
let proxy_server = tokio::spawn(async move {
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = proxy_sock.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&buf[..n]);
if request.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let response = "HTTP/1.1 403 Forbidden\r\n\r\n";
proxy_sock.write_all(response.as_bytes()).await.unwrap();
});
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let config = http_connect_config(proxy_addr);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
match result.unwrap_err() {
ChannelProxyError::HttpConnectProxyRejected(msg) => {
assert!(msg.contains("403"));
}
other => panic!("expected HttpConnectProxyRejected, got {:?}", other),
}
let _ = proxy_server.await;
}
#[tokio::test]
async fn target_unreachable_returns_appropriate_error() {
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
let result = connect_outbound(target, &direct_config()).await;
match result.unwrap_err() {
ChannelProxyError::TargetUnreachable
| ChannelProxyError::ConnectionRefused
| ChannelProxyError::Io(_) => {}
other => panic!("unexpected error type: {:?}", other),
}
}
#[tokio::test]
async fn socks5_proxy_unreachable() {
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let bad_proxy: SocketAddr = "127.0.0.1:1".parse().unwrap();
let config = socks5_config(bad_proxy);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn http_connect_proxy_unreachable() {
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let bad_proxy: SocketAddr = "127.0.0.1:1".parse().unwrap();
let config = http_connect_config(bad_proxy);
let result = connect_outbound(target, &config).await;
assert!(result.is_err());
}
struct MockChannel {
read_half: tokio::io::ReadHalf<DuplexStream>,
write_half: tokio::io::WriteHalf<DuplexStream>,
}
impl tokio::io::AsyncRead for MockChannel {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().read_half).poll_read(cx, buf)
}
}
impl tokio::io::AsyncWrite for MockChannel {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
std::pin::Pin::new(&mut self.get_mut().write_half).poll_write(cx, buf)
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().write_half).poll_flush(cx)
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().write_half).poll_shutdown(cx)
}
}
fn make_mock_channel() -> (MockChannel, DuplexStream) {
let (client, server) = duplex(4096);
let (read_half, write_half) = tokio::io::split(client);
(
MockChannel {
read_half,
write_half,
},
server,
)
}
#[tokio::test]
async fn proxy_channel_bidirectional_data_flow() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_addr = listener.local_addr().unwrap();
let echo_server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 64];
let n = sock.read(&mut buf).await.unwrap();
sock.write_all(&buf[..n]).await.unwrap();
});
let (channel, mut channel_peer) = make_mock_channel();
let target = target_addr;
let proxy = direct_config();
tokio::spawn(async move {
proxy_channel(channel, target, &proxy).await;
});
channel_peer.write_all(b"ping").await.unwrap();
channel_peer.flush().await.unwrap();
let mut buf = [0u8; 4];
channel_peer.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"ping");
drop(channel_peer);
let _ = echo_server.await;
}
#[tokio::test]
async fn proxy_channel_target_unreachable_closes_cleanly() {
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
let (channel, _channel_peer) = make_mock_channel();
let proxy = direct_config();
proxy_channel(channel, target, &proxy).await;
}
}

View File

@@ -1,196 +0,0 @@
//! Control channel routing for reserved `alknet-*` destinations.
//!
//! SSH channels opened with a destination starting with `alknet-` are intercepted
//! by the server and routed to a `ControlChannelHandler` instead of proxied to a
//! TCP target. See ADR-018 for the design rationale.
use std::io;
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite};
pub const ALKNET_CONTROL_DESTINATION: &str = "alknet-control";
pub const ALKNET_PREFIX: &str = "alknet-";
pub fn is_reserved_destination(host: &str) -> bool {
host.starts_with(ALKNET_PREFIX)
}
pub trait DuplexStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> DuplexStream for T {}
#[async_trait]
pub trait ControlChannelHandler: Send + Sync {
async fn handle_channel(&self, stream: Box<dyn DuplexStream>);
}
pub struct ControlChannelRouter {
handler: Option<Box<dyn ControlChannelHandler>>,
}
impl ControlChannelRouter {
pub fn new(handler: Option<Box<dyn ControlChannelHandler>>) -> Self {
Self { handler }
}
pub fn without_handler() -> Self {
Self { handler: None }
}
pub fn with_handler(handler: Box<dyn ControlChannelHandler>) -> Self {
Self {
handler: Some(handler),
}
}
pub fn has_handler(&self) -> bool {
self.handler.is_some()
}
pub async fn route(&self, stream: Box<dyn DuplexStream>) -> io::Result<()> {
match &self.handler {
Some(handler) => {
handler.handle_channel(stream).await;
Ok(())
}
None => Err(io::Error::new(
io::ErrorKind::ConnectionRefused,
"no control channel handler configured",
)),
}
}
pub fn take_handler(&mut self) -> Option<Box<dyn ControlChannelHandler>> {
self.handler.take()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::duplex;
#[test]
fn alknet_control_destination_constant() {
assert_eq!(ALKNET_CONTROL_DESTINATION, "alknet-control");
}
#[test]
fn alknet_prefix_constant() {
assert_eq!(ALKNET_PREFIX, "alknet-");
}
#[test]
fn reserved_destination_detected() {
assert!(is_reserved_destination("alknet-control"));
assert!(is_reserved_destination("alknet-status"));
assert!(is_reserved_destination("alknet-events"));
assert!(is_reserved_destination("alknet-"));
}
#[test]
fn non_reserved_destination_passes_through() {
assert!(!is_reserved_destination("example.com"));
assert!(!is_reserved_destination("localhost"));
assert!(!is_reserved_destination("192.168.1.1"));
assert!(!is_reserved_destination("alknet.example.com"));
assert!(!is_reserved_destination(""));
assert!(!is_reserved_destination("alkne-control"));
assert!(!is_reserved_destination("ALKNET-control"));
}
#[test]
fn prefix_matching_case_sensitive() {
assert!(!is_reserved_destination("Alknet-control"));
assert!(!is_reserved_destination("ALKNET-control"));
assert!(is_reserved_destination("alknet-Control"));
}
#[test]
fn router_without_handler_has_no_handler() {
let router = ControlChannelRouter::without_handler();
assert!(!router.has_handler());
}
#[test]
fn router_with_handler_has_handler() {
struct DummyHandler;
#[async_trait]
impl ControlChannelHandler for DummyHandler {
async fn handle_channel(&self, _stream: Box<dyn DuplexStream>) {}
}
let router = ControlChannelRouter::with_handler(Box::new(DummyHandler));
assert!(router.has_handler());
}
#[tokio::test]
async fn route_without_handler_returns_error() {
let router = ControlChannelRouter::without_handler();
let (_client, server) = duplex(64);
let stream: Box<dyn DuplexStream> = Box::new(server);
let result = router.route(stream).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::ConnectionRefused);
}
#[tokio::test]
async fn route_with_handler_succeeds() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
struct TrackedHandler {
called: Arc<AtomicBool>,
}
#[async_trait]
impl ControlChannelHandler for TrackedHandler {
async fn handle_channel(&self, _stream: Box<dyn DuplexStream>) {
self.called.store(true, Ordering::SeqCst);
}
}
let called = Arc::new(AtomicBool::new(false));
let handler = TrackedHandler {
called: called.clone(),
};
let router = ControlChannelRouter::with_handler(Box::new(handler));
let (_client, server) = duplex(64);
let stream: Box<dyn DuplexStream> = Box::new(server);
let result = router.route(stream).await;
assert!(result.is_ok());
assert!(called.load(Ordering::SeqCst));
}
#[tokio::test]
async fn route_with_handler_can_read_write() {
struct EchoHandler;
#[async_trait]
impl ControlChannelHandler for EchoHandler {
async fn handle_channel(&self, mut stream: Box<dyn DuplexStream>) {
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).await.unwrap();
stream.write_all(&buf[..n]).await.unwrap();
}
}
let router = ControlChannelRouter::with_handler(Box::new(EchoHandler));
let (client, server) = duplex(64);
let stream: Box<dyn DuplexStream> = Box::new(server);
tokio::spawn(async move {
router.route(stream).await.unwrap();
});
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut client = client;
client.write_all(b"hello").await.unwrap();
let mut buf = [0u8; 5];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
}
#[test]
fn control_channel_destination_matches_prefix() {
assert!(is_reserved_destination(ALKNET_CONTROL_DESTINATION));
}
}

View File

@@ -1,974 +0,0 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;
use arc_swap::ArcSwap;
use async_trait::async_trait;
use russh::keys::ssh_key::HashAlg;
use russh::server::{Auth, Handler, Msg, Session};
use russh::Channel;
use russh::ChannelId;
use crate::auth::identity::{ConfigIdentityProvider, Identity, IdentityProvider};
use crate::config::DynamicConfig;
use crate::server::control_channel::{ControlChannelHandler, ControlChannelRouter, ALKNET_PREFIX};
use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
pub use crate::transport::TransportKind;
#[derive(Debug, Clone)]
pub enum ProxyMode {
Direct,
Socks5(SocketAddr),
HttpConnect(SocketAddr),
}
#[derive(Debug, Clone)]
pub struct ProxyConfig {
pub mode: ProxyMode,
}
pub struct ServerHandler {
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
#[allow(dead_code)]
outbound_proxy: Option<ProxyConfig>,
remote_addr: Option<SocketAddr>,
control_channel_router: ControlChannelRouter,
#[allow(dead_code)]
transport: TransportKind,
connection_limiter: Arc<ConnectionRateLimiter>,
connection_allowed: bool,
auth_limiter: AuthAttemptLimiter,
connected_at: Instant,
authenticated_identity: Option<Identity>,
}
impl ServerHandler {
pub fn new(
dynamic: Arc<ArcSwap<DynamicConfig>>,
outbound_proxy: Option<ProxyConfig>,
remote_addr: Option<SocketAddr>,
transport: TransportKind,
connection_limiter: Arc<ConnectionRateLimiter>,
max_auth_attempts: usize,
) -> Self {
let identity_provider: Arc<dyn IdentityProvider> =
Arc::new(ConfigIdentityProvider::new(Arc::clone(&dynamic)));
let allowed = if let Some(addr) = remote_addr {
let ip = addr.ip();
if connection_limiter.check(ip) {
connection_limiter.on_connect(ip);
tracing::info!(
remote_addr = %addr,
transport = %transport,
"connection opened"
);
true
} else {
tracing::info!(
remote_addr = %addr,
transport = %transport,
"connection rejected"
);
false
}
} else {
true
};
Self {
dynamic,
identity_provider,
outbound_proxy,
remote_addr,
control_channel_router: ControlChannelRouter::without_handler(),
transport,
connection_limiter,
connection_allowed: allowed,
auth_limiter: AuthAttemptLimiter::new(max_auth_attempts),
connected_at: Instant::now(),
authenticated_identity: None,
}
}
pub fn with_identity_provider(mut self, provider: Arc<dyn IdentityProvider>) -> Self {
self.identity_provider = provider;
self
}
pub fn authenticated_identity(&self) -> Option<&Identity> {
self.authenticated_identity.as_ref()
}
pub fn is_connection_allowed(&self) -> bool {
self.connection_allowed
}
pub fn remote_ip(&self) -> Option<IpAddr> {
self.remote_addr.map(|a| a.ip())
}
}
impl Drop for ServerHandler {
fn drop(&mut self) {
if let Some(addr) = self.remote_addr {
if self.connection_allowed {
self.connection_limiter.on_disconnect(addr.ip());
}
let duration = self.connected_at.elapsed();
tracing::info!(
remote_addr = %addr,
duration_secs = duration.as_secs_f64(),
"connection closed"
);
}
}
}
impl ServerHandler {
pub fn with_control_channel_handler(mut self, handler: Box<dyn ControlChannelHandler>) -> Self {
self.control_channel_router = ControlChannelRouter::with_handler(handler);
self
}
pub fn control_channel_router(&self) -> &ControlChannelRouter {
&self.control_channel_router
}
}
#[async_trait]
impl Handler for ServerHandler {
type Error = russh::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<Auth, Self::Error> {
if !self.auth_limiter.check() {
let remote_addr_display = self
.remote_addr
.map_or("unknown".to_string(), |a| a.to_string());
let fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256));
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "reject",
"auth attempt"
);
return Ok(Auth::Reject {
proceed_with_methods: None,
});
}
let fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256));
let remote_addr_display = self
.remote_addr
.map_or("unknown".to_string(), |a| a.to_string());
let identity = self
.identity_provider
.resolve_from_fingerprint(&fingerprint);
match identity {
Some(id) => {
self.authenticated_identity = Some(id);
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "accept",
"auth attempt"
);
Ok(Auth::Accept)
}
None => {
self.auth_limiter.on_failure();
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "reject",
"auth attempt"
);
Ok(Auth::Reject {
proceed_with_methods: None,
})
}
}
}
async fn channel_open_direct_tcpip(
&mut self,
channel: Channel<Msg>,
host_to_connect: &str,
port_to_connect: u32,
originator_address: &str,
originator_port: u32,
_session: &mut Session,
) -> Result<bool, Self::Error> {
if host_to_connect.starts_with(ALKNET_PREFIX) {
if !self.control_channel_router.has_handler() {
return Ok(false);
}
let _ = channel;
return Ok(true);
}
let identity = self
.authenticated_identity
.clone()
.unwrap_or_else(|| Identity {
id: String::new(),
scopes: vec![],
resources: std::collections::HashMap::new(),
});
let policy = self.dynamic.load();
let allowed = policy.forwarding.check(
host_to_connect,
port_to_connect as u16,
&identity,
self.transport.clone(),
);
if !allowed {
tracing::info!(
remote_addr = ?self.remote_addr,
target = %format!("{host_to_connect}:{port_to_connect}"),
identity = %identity.id,
transport = %self.transport,
"forwarding denied by policy"
);
return Ok(false);
}
let target_host = host_to_connect.to_string();
let target_port = port_to_connect;
let proxy_config = self.outbound_proxy.clone().unwrap_or(ProxyConfig {
mode: ProxyMode::Direct,
});
tokio::spawn(async move {
let target =
match format!("{target_host}:{target_port}").parse::<std::net::SocketAddr>() {
Ok(addr) => addr,
Err(_) => match tokio::net::lookup_host((&target_host[..], target_port as u16))
.await
{
Ok(mut addrs) => match addrs.next() {
Some(addr) => addr,
None => return,
},
Err(_) => return,
},
};
crate::server::channel_proxy::proxy_channel(
channel.into_stream(),
target,
&proxy_config,
)
.await;
});
let _ = (originator_address, originator_port);
Ok(true)
}
async fn channel_open_session(
&mut self,
_channel: Channel<Msg>,
session: &mut Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
"rejected session channel (shell/exec not supported)"
);
let _ = session;
Ok(false)
}
async fn channel_open_x11(
&mut self,
_channel: Channel<Msg>,
_originator_address: &str,
_originator_port: u32,
session: &mut Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
"rejected x11 channel"
);
let _ = session;
Ok(false)
}
async fn channel_open_forwarded_tcpip(
&mut self,
_channel: Channel<Msg>,
host_to_connect: &str,
port_to_connect: u32,
_originator_address: &str,
_originator_port: u32,
session: &mut Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
target = %format!("{host_to_connect}:{port_to_connect}"),
"rejected forwarded-tcpip channel (remote port forwarding not supported)"
);
let _ = session;
Ok(false)
}
async fn exec_request(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
data_len = data.len(),
"rejected exec request on channel (shell/exec not supported)"
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
"rejected shell request on channel"
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn subsystem_request(
&mut self,
channel: ChannelId,
name: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
subsystem = name,
"rejected subsystem request on channel"
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn pty_request(
&mut self,
channel: ChannelId,
term: &str,
col_width: u32,
row_height: u32,
pix_width: u32,
pix_height: u32,
modes: &[(russh::Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
term = term,
"rejected pty request on channel"
);
let _ = (col_width, row_height, pix_width, pix_height, modes);
let _ = session.channel_failure(channel);
Ok(())
}
async fn env_request(
&mut self,
channel: ChannelId,
variable_name: &str,
variable_value: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
variable = variable_name,
"rejected env request on channel"
);
let _ = variable_value;
let _ = session.channel_failure(channel);
Ok(())
}
async fn x11_request(
&mut self,
channel: ChannelId,
single_connection: bool,
x11_auth_protocol: &str,
x11_auth_cookie: &str,
x11_screen_number: u32,
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
"rejected x11 request on channel"
);
let _ = (
single_connection,
x11_auth_protocol,
x11_auth_cookie,
x11_screen_number,
);
let _ = session.channel_failure(channel);
Ok(())
}
async fn agent_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
channel = %channel,
"rejected agent forwarding request on channel"
);
let _ = session;
Ok(false)
}
async fn tcpip_forward(
&mut self,
address: &str,
port: &mut u32,
session: &mut Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
address = address,
port = *port,
"rejected tcpip-forward request (remote port forwarding not supported)"
);
let _ = session;
Ok(false)
}
async fn cancel_tcpip_forward(
&mut self,
address: &str,
port: u32,
session: &mut Session,
) -> Result<bool, Self::Error> {
let _ = (address, port, session);
Ok(false)
}
async fn streamlocal_forward(
&mut self,
socket_path: &str,
session: &mut Session,
) -> Result<bool, Self::Error> {
tracing::warn!(
remote_addr = ?self.remote_addr,
socket_path = socket_path,
"rejected streamlocal-forward request"
);
let _ = session;
Ok(false)
}
async fn signal(
&mut self,
channel: ChannelId,
signal: russh::Sig,
session: &mut Session,
) -> Result<(), Self::Error> {
tracing::debug!(
remote_addr = ?self.remote_addr,
channel = %channel,
signal = ?signal,
"received signal on channel (ignored)"
);
let _ = session;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::keys::KeySource;
use crate::auth::ServerAuthConfig;
use crate::config::AuthPolicy;
use russh::keys::{decode_secret_key, PrivateKey};
use std::io::Write;
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096";
fn make_authorized_keys_file(keys_content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(keys_content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn load_key() -> PrivateKey {
decode_secret_key(ED25519_PRIVATE_KEY, None).unwrap()
}
fn make_auth_config(keys_content: &str) -> Arc<ArcSwap<DynamicConfig>> {
let f = make_authorized_keys_file(keys_content);
let server_auth =
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
.unwrap();
let auth_policy = AuthPolicy::from_server_auth_config(server_auth);
let dynamic = DynamicConfig::new(auth_policy);
Arc::new(ArcSwap::new(Arc::new(dynamic)))
}
fn make_empty_auth_config() -> Arc<ArcSwap<DynamicConfig>> {
let dynamic = DynamicConfig::default();
Arc::new(ArcSwap::new(Arc::new(dynamic)))
}
fn default_limiter() -> Arc<ConnectionRateLimiter> {
Arc::new(ConnectionRateLimiter::new(0))
}
fn make_handler(
dynamic: Arc<ArcSwap<DynamicConfig>>,
outbound_proxy: Option<ProxyConfig>,
remote_addr: Option<SocketAddr>,
) -> ServerHandler {
ServerHandler::new(
dynamic,
outbound_proxy,
remote_addr,
TransportKind::Tcp,
default_limiter(),
10,
)
}
#[tokio::test]
async fn auth_delegation_accepts_known_key() {
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
let mut handler = make_handler(auth_config, None, None);
let ssh_key = load_key().public_key().clone();
let result = handler.auth_publickey("testuser", &ssh_key).await.unwrap();
assert_eq!(result, Auth::Accept);
}
#[tokio::test]
async fn auth_delegation_rejects_unknown_key() {
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
let mut handler = make_handler(auth_config, None, None);
let other_key_text = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE other@host";
let other_ssh_key =
russh::keys::parse_public_key_base64(other_key_text.split_whitespace().nth(1).unwrap())
.unwrap();
let result = handler
.auth_publickey("testuser", &other_ssh_key)
.await
.unwrap();
assert_eq!(
result,
Auth::Reject {
proceed_with_methods: None
}
);
}
#[tokio::test]
async fn auth_delegation_empty_config_rejects_all() {
let auth_config = make_empty_auth_config();
let mut handler = make_handler(auth_config, None, None);
let ssh_key = load_key().public_key().clone();
let result = handler.auth_publickey("testuser", &ssh_key).await.unwrap();
assert_eq!(
result,
Auth::Reject {
proceed_with_methods: None
}
);
}
#[tokio::test]
async fn auth_logging_includes_remote_addr() {
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
let remote_addr: SocketAddr = "203.0.113.50:12345".parse().unwrap();
let mut handler = make_handler(auth_config, None, Some(remote_addr));
let ssh_key = load_key().public_key().clone();
let _ = handler.auth_publickey("root", &ssh_key).await.unwrap();
}
#[test]
fn reserved_alknet_destination_routing() {
use crate::server::control_channel::is_reserved_destination;
assert!(is_reserved_destination("alknet-control"));
assert!(is_reserved_destination("alknet-status"));
assert!(is_reserved_destination("alknet-events"));
assert!(!is_reserved_destination("example.com"));
assert!(!is_reserved_destination("localhost"));
assert!(!is_reserved_destination("alknet.example.com"));
}
#[test]
fn server_handler_without_control_handler_rejects_alknet_destinations() {
let auth_config = make_empty_auth_config();
let handler = make_handler(auth_config, None, None);
assert!(!handler.control_channel_router().has_handler());
}
#[test]
fn proxy_mode_variants() {
let direct = ProxyMode::Direct;
let socks5 = ProxyMode::Socks5("127.0.0.1:9050".parse().unwrap());
let http = ProxyMode::HttpConnect("127.0.0.1:8080".parse().unwrap());
match direct {
ProxyMode::Direct => {}
_ => panic!("expected Direct"),
}
match socks5 {
ProxyMode::Socks5(_) => {}
_ => panic!("expected Socks5"),
}
match http {
ProxyMode::HttpConnect(_) => {}
_ => panic!("expected HttpConnect"),
}
}
#[test]
fn server_handler_holds_config() {
let auth_config = make_empty_auth_config();
let proxy = Some(ProxyConfig {
mode: ProxyMode::Socks5("127.0.0.1:9050".parse().unwrap()),
});
let remote: Option<SocketAddr> = Some("10.0.0.1:22".parse().unwrap());
let handler = make_handler(auth_config, proxy.clone(), remote);
assert!(handler.outbound_proxy.is_some());
assert!(handler.remote_addr.is_some());
}
#[test]
fn one_handler_per_connection() {
let auth_config = make_empty_auth_config();
let handler1 = make_handler(
auth_config.clone(),
None,
Some("10.0.0.1:22".parse().unwrap()),
);
let handler2 = make_handler(
auth_config.clone(),
None,
Some("10.0.0.2:22".parse().unwrap()),
);
assert!(handler1.remote_addr != handler2.remote_addr);
}
#[tokio::test]
async fn auth_rate_limit_rejects_after_max_failures() {
let auth_config = make_empty_auth_config();
let limiter = Arc::new(ConnectionRateLimiter::new(0));
let mut handler = ServerHandler::new(
auth_config,
None,
Some("10.0.0.1:22".parse().unwrap()),
TransportKind::Tcp,
limiter,
2,
);
let ssh_key = load_key().public_key().clone();
let r1 = handler.auth_publickey("user", &ssh_key).await.unwrap();
assert_eq!(
r1,
Auth::Reject {
proceed_with_methods: None
}
);
let r2 = handler.auth_publickey("user", &ssh_key).await.unwrap();
assert_eq!(
r2,
Auth::Reject {
proceed_with_methods: None
}
);
assert!(!handler.auth_limiter.check());
}
#[test]
fn connection_rate_limit_blocks_over_limit() {
let limiter = Arc::new(ConnectionRateLimiter::new(1));
let auth_config = make_empty_auth_config();
let addr: SocketAddr = "10.0.0.1:22".parse().unwrap();
let h1 = ServerHandler::new(
auth_config.clone(),
None,
Some(addr),
TransportKind::Tcp,
limiter.clone(),
10,
);
assert!(h1.is_connection_allowed());
let h2 = ServerHandler::new(
auth_config.clone(),
None,
Some(addr),
TransportKind::Tcp,
limiter.clone(),
10,
);
assert!(!h2.is_connection_allowed());
drop(h1);
let h3 = ServerHandler::new(
auth_config,
None,
Some(addr),
TransportKind::Tcp,
limiter,
10,
);
assert!(h3.is_connection_allowed());
}
#[test]
fn transport_kind_display() {
assert_eq!(TransportKind::Tcp.to_string(), "tcp");
assert_eq!(TransportKind::Tls { server_name: None }.to_string(), "tls");
assert_eq!(
TransportKind::Iroh {
endpoint_id: String::new()
}
.to_string(),
"iroh"
);
assert_eq!(
TransportKind::WebTransport { server_name: None }.to_string(),
"webtransport"
);
}
#[tokio::test]
async fn auth_log_includes_user_field() {
let auth_config = make_empty_auth_config();
let mut handler = ServerHandler::new(
auth_config,
None,
Some("203.0.113.50:12345".parse().unwrap()),
TransportKind::Tls { server_name: None },
Arc::new(ConnectionRateLimiter::new(0)),
10,
);
let ssh_key = load_key().public_key().clone();
let _ = handler.auth_publickey("root", &ssh_key).await.unwrap();
}
#[test]
fn connection_closed_logs_duration_on_drop() {
let auth_config = make_empty_auth_config();
let _handler = ServerHandler::new(
auth_config,
None,
Some("203.0.113.50:12345".parse().unwrap()),
TransportKind::Tcp,
Arc::new(ConnectionRateLimiter::new(0)),
10,
);
}
#[tokio::test]
async fn config_reload_new_keys_take_effect() {
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
let mut handler = ServerHandler::new(
auth_config.clone(),
None,
None,
TransportKind::Tcp,
default_limiter(),
10,
);
let ssh_key = load_key().public_key().clone();
let result = handler.auth_publickey("testuser", &ssh_key).await.unwrap();
assert_eq!(result, Auth::Accept);
drop(handler);
let new_dynamic = DynamicConfig::default();
auth_config.store(Arc::new(new_dynamic));
let mut handler2 = ServerHandler::new(
auth_config.clone(),
None,
None,
TransportKind::Tcp,
default_limiter(),
10,
);
let result2 = handler2.auth_publickey("testuser", &ssh_key).await.unwrap();
assert_eq!(
result2,
Auth::Reject {
proceed_with_methods: None
}
);
}
#[tokio::test]
async fn forwarding_policy_deny_blocks_channel_open() {
use crate::config::forwarding::{
ForwardingAction, ForwardingPolicy, ForwardingRule, TargetPattern,
};
let deny_policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Host("blocked.example.com".to_string()),
action: ForwardingAction::Deny,
principals: vec![],
transports: vec![],
}],
};
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
{
let dynamic = auth_config.load();
let new_dynamic = DynamicConfig {
auth: dynamic.auth.clone(),
forwarding: deny_policy,
rate_limits: dynamic.rate_limits.clone(),
credentials: dynamic.credentials.clone(),
};
drop(dynamic);
auth_config.store(Arc::new(new_dynamic));
}
let mut handler = ServerHandler::new(
auth_config,
None,
Some("127.0.0.1:12345".parse().unwrap()),
TransportKind::Tcp,
default_limiter(),
10,
);
let ssh_key = load_key().public_key().clone();
let result = handler.auth_publickey("testuser", &ssh_key).await.unwrap();
assert_eq!(result, Auth::Accept);
assert!(handler.authenticated_identity().is_some());
let identity = handler.authenticated_identity().unwrap();
let dynamic = handler.dynamic.load();
assert!(!dynamic.forwarding.check(
"blocked.example.com",
443,
identity,
TransportKind::Tcp
));
}
#[test]
fn forwarding_policy_deny_with_custom_identity() {
use crate::config::forwarding::{
ForwardingAction, ForwardingPolicy, ForwardingRule, TargetPattern,
};
use std::collections::HashMap;
let mut resources = HashMap::new();
resources.insert("service".to_string(), vec!["gitea".to_string()]);
let identity = Identity {
id: "SHA256:abc123".to_string(),
scopes: vec!["relay:connect".to_string()],
resources,
};
let policy = ForwardingPolicy {
default: ForwardingAction::Deny,
rules: vec![ForwardingRule {
target: TargetPattern::Host("allowed.example.com".to_string()),
action: ForwardingAction::Allow,
principals: vec!["SHA256:abc123".to_string()],
transports: vec![TransportKind::Tcp],
}],
};
assert!(policy.check("allowed.example.com", 443, &identity, TransportKind::Tcp));
assert!(!policy.check("denied.example.com", 443, &identity, TransportKind::Tcp));
}
#[test]
fn server_handler_with_custom_identity_provider() {
use std::collections::HashMap;
struct MockIdentityProvider {
identities: HashMap<String, Identity>,
}
impl IdentityProvider for MockIdentityProvider {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
self.identities.get(fingerprint).cloned()
}
fn resolve_from_token(&self, _token: &crate::auth::AuthToken) -> Option<Identity> {
None
}
}
let mut identities = HashMap::new();
identities.insert(
"SHA256:testkey".to_string(),
Identity {
id: "SHA256:testkey".to_string(),
scopes: vec!["admin".to_string()],
resources: HashMap::new(),
},
);
let provider = Arc::new(MockIdentityProvider { identities }) as Arc<dyn IdentityProvider>;
let dynamic = make_empty_auth_config();
let handler = ServerHandler::new(
dynamic,
None,
Some("10.0.0.1:22".parse().unwrap()),
TransportKind::Tcp,
default_limiter(),
10,
)
.with_identity_provider(provider);
assert!(handler.authenticated_identity().is_none());
}
}

View File

@@ -1,33 +0,0 @@
//! Server-side SSH connection handling.
//!
//! Provides `Server` for accepting SSH connections over any transport and proxying
//! `direct-tcpip` channel requests to targets. Supports Ed25519 and certificate-authority
//! auth, connection rate limiting, auth attempt limiting, stealth mode (fake nginx 404),
//! and outbound proxy routing (direct/SOCKS5/HTTP CONNECT).
//!
//! Destination hosts starting with `alknet-` are reserved for internal use (control channel, ADR-018).
pub mod channel_proxy;
pub mod control_channel;
pub mod handler;
pub mod rate_limit;
pub mod serve;
pub mod stealth;
pub use channel_proxy::{connect_outbound, proxy_channel};
pub use control_channel::{
is_reserved_destination, ControlChannelHandler, ControlChannelRouter, DuplexStream,
ALKNET_CONTROL_DESTINATION, ALKNET_PREFIX,
};
pub use handler::{ProxyConfig, ProxyMode, ServerHandler};
pub use rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
pub use serve::{
DnsListenerConfig, HttpListenerConfig, ListenerConfig, ServeError, ServeOptions,
ServeTransportMode, Server, StreamListenerConfig,
};
pub use crate::transport::TransportKind;
pub use stealth::{
detect_protocol, handle_http_stealth, send_fake_nginx_404, validate_stealth_config,
ProtocolDetection,
};

View File

@@ -1,200 +0,0 @@
//! Connection rate limiting and auth attempt limiting.
//!
//! `ConnectionRateLimiter` tracks per-IP active connections (thread-safe).
//! `AuthAttemptLimiter` caps failed auth attempts per connection.
//! These complement fail2ban on Linux and provide abuse protection on all platforms.
//! See ADR-013.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
pub struct ConnectionRateLimiter {
max_per_ip: usize,
active: Mutex<HashMap<IpAddr, usize>>,
}
impl ConnectionRateLimiter {
pub fn new(max_per_ip: usize) -> Self {
Self {
max_per_ip,
active: Mutex::new(HashMap::new()),
}
}
pub fn check(&self, ip: IpAddr) -> bool {
if self.max_per_ip == 0 {
return true;
}
let active = self.active.lock().unwrap();
let count = active.get(&ip).copied().unwrap_or(0);
count < self.max_per_ip
}
pub fn on_connect(&self, ip: IpAddr) {
let mut active = self.active.lock().unwrap();
*active.entry(ip).or_insert(0) += 1;
}
pub fn on_disconnect(&self, ip: IpAddr) {
let mut active = self.active.lock().unwrap();
if let Some(count) = active.get_mut(&ip) {
if *count > 1 {
*count -= 1;
} else {
active.remove(&ip);
}
}
}
}
pub struct AuthAttemptLimiter {
max_attempts: usize,
failures: usize,
}
impl AuthAttemptLimiter {
pub fn new(max_attempts: usize) -> Self {
Self {
max_attempts,
failures: 0,
}
}
pub fn check(&self) -> bool {
if self.max_attempts == 0 {
return true;
}
self.failures < self.max_attempts
}
pub fn on_failure(&mut self) {
self.failures += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn ip(n: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(192, 168, 1, n))
}
#[test]
fn connection_limiter_allows_when_under_limit() {
let limiter = ConnectionRateLimiter::new(3);
assert!(limiter.check(ip(1)));
}
#[test]
fn connection_limiter_blocks_when_at_limit() {
let limiter = ConnectionRateLimiter::new(2);
limiter.on_connect(ip(1));
limiter.on_connect(ip(1));
assert!(!limiter.check(ip(1)));
}
#[test]
fn connection_limiter_allows_after_disconnect() {
let limiter = ConnectionRateLimiter::new(2);
limiter.on_connect(ip(1));
limiter.on_connect(ip(1));
assert!(!limiter.check(ip(1)));
limiter.on_disconnect(ip(1));
assert!(limiter.check(ip(1)));
}
#[test]
fn connection_limiter_unlimited_when_zero() {
let limiter = ConnectionRateLimiter::new(0);
for _ in 0..100 {
limiter.on_connect(ip(1));
}
assert!(limiter.check(ip(1)));
}
#[test]
fn connection_limiter_tracks_per_ip_independently() {
let limiter = ConnectionRateLimiter::new(1);
limiter.on_connect(ip(1));
assert!(!limiter.check(ip(1)));
assert!(limiter.check(ip(2)));
}
#[test]
fn connection_limiter_ipv6() {
let limiter = ConnectionRateLimiter::new(1);
let ip6 = IpAddr::V6(Ipv6Addr::LOCALHOST);
limiter.on_connect(ip6);
assert!(!limiter.check(ip6));
}
#[test]
fn connection_limiter_disconnect_removes_zero_entry() {
let limiter = ConnectionRateLimiter::new(3);
limiter.on_connect(ip(1));
limiter.on_disconnect(ip(1));
{
let active = limiter.active.lock().unwrap();
assert!(!active.contains_key(&ip(1)));
}
}
#[test]
fn auth_limiter_allows_when_under_limit() {
let limiter = AuthAttemptLimiter::new(3);
assert!(limiter.check());
}
#[test]
fn auth_limiter_blocks_after_max_failures() {
let mut limiter = AuthAttemptLimiter::new(2);
limiter.on_failure();
limiter.on_failure();
assert!(!limiter.check());
}
#[test]
fn auth_limiter_unlimited_when_zero() {
let mut limiter = AuthAttemptLimiter::new(0);
for _ in 0..100 {
limiter.on_failure();
}
assert!(limiter.check());
}
#[test]
fn auth_limiter_still_allows_at_one_below_limit() {
let mut limiter = AuthAttemptLimiter::new(3);
limiter.on_failure();
limiter.on_failure();
assert!(limiter.check());
limiter.on_failure();
assert!(!limiter.check());
}
#[test]
fn connection_limiter_thread_safety() {
use std::sync::Arc;
use std::thread;
let limiter = Arc::new(ConnectionRateLimiter::new(100));
let mut handles = vec![];
for i in 0..10 {
let lim = Arc::clone(&limiter);
handles.push(thread::spawn(move || {
let ip_addr = ip((i % 3) as u8 + 1);
lim.on_connect(ip_addr);
assert!(lim.check(ip_addr));
lim.on_disconnect(ip_addr);
}));
}
for h in handles {
h.join().unwrap();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,316 +0,0 @@
//! Stealth mode: protocol detection on TLS connections.
//!
//! When stealth mode is enabled with TLS transport, the server peeks at the first
//! bytes after the TLS handshake to determine whether the client is speaking SSH
//! or HTTP. When the `http` feature is enabled, detected HTTP traffic is routed to
//! the axum router. When `http` is disabled, non-SSH connections receive a fake
//! nginx 404 response, making the server appear as an ordinary web server to port
//! scanners and DPI systems. See ADR-017.
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use crate::auth::IdentityProvider;
const SSH_BANNER_PREFIX: &[u8] = b"SSH-2.0-";
const FAKE_NGINX_404: &[u8] = b"HTTP/1.1 404 Not Found\r\nServer: nginx\r\n\r\n";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolDetection {
Ssh,
Http,
}
pub async fn detect_protocol<S>(stream: S) -> (ProtocolDetection, BufReader<S>)
where
S: AsyncRead + Unpin,
{
let mut reader = BufReader::new(stream);
let detection = match reader.fill_buf().await {
Ok(buf) if buf.len() >= SSH_BANNER_PREFIX.len() => {
if &buf[..SSH_BANNER_PREFIX.len()] == SSH_BANNER_PREFIX {
ProtocolDetection::Ssh
} else {
ProtocolDetection::Http
}
}
Ok(buf) if !buf.is_empty() => {
if buf.starts_with(SSH_BANNER_PREFIX) {
ProtocolDetection::Ssh
} else {
ProtocolDetection::Http
}
}
_ => ProtocolDetection::Http,
};
(detection, reader)
}
pub async fn send_fake_nginx_404<S>(reader: &mut BufReader<S>)
where
S: AsyncRead + AsyncWrite + Unpin,
{
let _ = reader.get_mut().write_all(FAKE_NGINX_404).await;
let _ = reader.get_mut().shutdown().await;
}
#[cfg(feature = "http")]
pub async fn handle_http_stealth<S>(
reader: BufReader<S>,
identity_provider: Arc<dyn IdentityProvider>,
) where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
crate::http::router::serve_connection_from_reader(reader, identity_provider).await
}
#[cfg(not(feature = "http"))]
pub async fn handle_http_stealth<S>(
mut reader: BufReader<S>,
_identity_provider: Arc<dyn IdentityProvider>,
) where
S: AsyncRead + AsyncWrite + Unpin,
{
send_fake_nginx_404(&mut reader).await
}
pub fn validate_stealth_config(stealth: bool, transport_is_tls: bool) -> Result<(), &'static str> {
if stealth && !transport_is_tls {
return Err("stealth mode requires TLS transport (--transport tls)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
async fn write_and_detect(data: &[u8]) -> ProtocolDetection {
let (client, server) = duplex(1024);
let mut client = client;
client.write_all(data).await.unwrap();
drop(client);
let (detection, _) = detect_protocol(server).await;
detection
}
#[tokio::test]
async fn ssh_banner_detected() {
let detection = write_and_detect(b"SSH-2.0-OpenSSH_9.0\r\n").await;
assert_eq!(detection, ProtocolDetection::Ssh);
}
#[tokio::test]
async fn ssh_banner_other_implementation() {
let detection = write_and_detect(b"SSH-2.0-russh_0.49\r\n").await;
assert_eq!(detection, ProtocolDetection::Ssh);
}
#[tokio::test]
async fn ssh_banner_minimal() {
let detection = write_and_detect(b"SSH-2.0-X\n").await;
assert_eq!(detection, ProtocolDetection::Ssh);
}
#[tokio::test]
async fn http_get_detected_as_http() {
let detection = write_and_detect(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn http_post_detected_as_http() {
let detection = write_and_detect(b"POST /api HTTP/1.1\r\nHost: example.com\r\n\r\n").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn random_data_detected_as_http() {
let detection = write_and_detect(b"\x01\x02\x03\x04\x05\x06\x07\x08").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn empty_stream_detected_as_http() {
let (client, server) = duplex(1024);
drop(client);
let (detection, _) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn ssh_banner_bytes_preserved_by_bufreader() {
let (client, server) = duplex(1024);
let mut client = client;
let banner = b"SSH-2.0-OpenSSH_9.0\r\n";
client.write_all(banner).await.unwrap();
client.write_all(b"subsequent data").await.unwrap();
drop(client);
let (detection, mut reader) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Ssh);
let mut all_data = Vec::new();
reader.read_to_end(&mut all_data).await.unwrap();
assert!(
all_data.starts_with(banner),
"banner bytes must be preserved after detection"
);
}
#[tokio::test]
async fn fake_nginx_404_response() {
let (client, server) = duplex(1024);
let (mut client_read, mut client_write) = tokio::io::split(client);
client_write
.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
.await
.unwrap();
drop(client_write);
let (detection, mut reader) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Http);
send_fake_nginx_404(&mut reader).await;
let mut buf = [0u8; 256];
let n = client_read.read(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(response.contains("HTTP/1.1 404 Not Found"));
assert!(response.contains("Server: nginx"));
}
#[tokio::test]
async fn protocol_detection_enum_equality() {
assert_eq!(ProtocolDetection::Ssh, ProtocolDetection::Ssh);
assert_eq!(ProtocolDetection::Http, ProtocolDetection::Http);
assert_ne!(ProtocolDetection::Ssh, ProtocolDetection::Http);
}
#[test]
fn validate_stealth_without_tls_rejected() {
let result = validate_stealth_config(true, false);
assert!(result.is_err());
assert!(result.unwrap_err().contains("TLS transport"));
}
#[test]
fn validate_stealth_with_tls_accepted() {
let result = validate_stealth_config(true, true);
assert!(result.is_ok());
}
#[test]
fn validate_no_stealth_with_tcp_accepted() {
let result = validate_stealth_config(false, false);
assert!(result.is_ok());
}
#[test]
fn validate_no_stealth_with_tls_accepted() {
let result = validate_stealth_config(false, true);
assert!(result.is_ok());
}
#[tokio::test]
async fn short_data_detected_as_http() {
let detection = write_and_detect(b"GE").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn partial_ssh_prefix_detected_as_http() {
let detection = write_and_detect(b"SSH-1.").await;
assert_eq!(detection, ProtocolDetection::Http);
}
#[tokio::test]
async fn http_request_gets_404_then_closed() {
let (client, server) = duplex(1024);
let mut client = client;
client
.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
.await
.unwrap();
let (detection, mut reader) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Http);
send_fake_nginx_404(&mut reader).await;
let mut buf = [0u8; 256];
let n = client.read(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(response.starts_with("HTTP/1.1 404 Not Found"));
assert!(response.contains("Server: nginx"));
let mut extra = [0u8; 16];
let result = client.read(&mut extra).await;
assert!(result.is_err() || result.unwrap() == 0);
}
#[cfg(feature = "http")]
#[tokio::test]
async fn stealth_handoff_routes_http_to_axum() {
use crate::auth::{AuthToken, IdentityProvider};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
struct NullProvider;
impl IdentityProvider for NullProvider {
fn resolve_from_fingerprint(
&self,
_fingerprint: &str,
) -> Option<crate::auth::Identity> {
None
}
fn resolve_from_token(&self, _token: &AuthToken) -> Option<crate::auth::Identity> {
None
}
}
let (client, server) = duplex(4096);
let (mut client_read, mut client_write) = tokio::io::split(client);
client_write
.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
.await
.unwrap();
drop(client_write);
let (detection, reader) = detect_protocol(server).await;
assert_eq!(detection, ProtocolDetection::Http);
let provider: Arc<dyn IdentityProvider> = Arc::new(NullProvider);
let handle = tokio::spawn(async move {
handle_http_stealth(reader, provider).await;
});
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut client_read, &mut buf)
.await
.unwrap();
let response = String::from_utf8_lossy(&buf);
assert!(
response.contains("401"),
"expected 401 from axum auth middleware, got: {response}"
);
assert!(
!response.contains("nginx"),
"should not contain fake nginx response when http feature is enabled"
);
let _ = handle.await;
}
}

View File

@@ -1,490 +0,0 @@
//! SOCKS5 proxy server.
//!
//! Listens on a local port and routes each SOCKS5 connection through an SSH
//! `direct-tcpip` channel. Supports SOCKS5h (domain names resolved server-side)
//! to prevent DNS leaks. Uses the `ChannelOpener` trait to abstract over the
//! SSH channel mechanism, making it testable without a real SSH session.
mod protocol;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tracing::debug;
use protocol::{Socks5Reply, Socks5Request, Socks5VersionMethod};
pub use protocol::Socks5Address;
const DEFAULT_SOCKS5_ADDR: &str = "127.0.0.1:1080";
pub trait ChannelOpener: Send + Sync + 'static {
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
fn open_channel(
&self,
host: String,
port: u16,
) -> impl std::future::Future<Output = Result<Self::Stream, ChannelOpenError>> + Send;
}
#[derive(Debug, thiserror::Error)]
pub enum ChannelOpenError {
#[error("session closed")]
SessionClosed,
#[error("channel open failed")]
ChannelOpenFailed,
#[error("connection refused")]
ConnectionRefused,
}
pub struct Socks5Server<C: ChannelOpener> {
listen_addr: SocketAddr,
channel_opener: Arc<C>,
}
impl<C: ChannelOpener> Socks5Server<C> {
pub fn new(channel_opener: C) -> Self {
Self::with_addr(channel_opener, DEFAULT_SOCKS5_ADDR)
}
pub fn with_addr(channel_opener: C, addr: &str) -> Self {
let listen_addr: SocketAddr = addr.parse().expect("invalid SOCKS5 listen address");
Self {
listen_addr,
channel_opener: Arc::new(channel_opener),
}
}
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
pub async fn run(self) -> Result<(), std::io::Error> {
let listener = TcpListener::bind(self.listen_addr).await?;
debug!("socks5 server listening on {}", self.listen_addr);
loop {
let (socket, _peer) = listener.accept().await?;
let opener = Arc::clone(&self.channel_opener);
tokio::spawn(async move {
if let Err(e) = handle_socks5_connection(socket, opener).await {
debug!("socks5 connection error: {e}");
}
});
}
}
}
async fn handle_socks5_connection<S, C>(mut socket: S, opener: Arc<C>) -> Result<(), Socks5Error>
where
S: AsyncRead + AsyncWrite + Unpin,
C: ChannelOpener,
{
let vm = Socks5VersionMethod::read_from(&mut socket).await?;
if vm.version != 0x05 {
return Err(Socks5Error::InvalidVersion(vm.version));
}
if !vm.methods.contains(&0x00) {
let reply = [0x05, 0xFF];
socket.write_all(&reply).await?;
socket.shutdown().await?;
return Err(Socks5Error::NoAcceptableAuth);
}
let reply = [0x05, 0x00];
socket.write_all(&reply).await?;
let request = Socks5Request::read_from(&mut socket).await?;
if request.version != 0x05 {
return Err(Socks5Error::InvalidVersion(request.version));
}
if request.command != 0x01 {
send_error_reply(&mut socket, Socks5Reply::command_not_supported()).await?;
return Err(Socks5Error::UnsupportedCommand(request.command));
}
let (host, port) = match &request.address {
Socks5Address::Ipv4(addr) => (addr.to_string(), request.port),
Socks5Address::Ipv6(addr) => (addr.to_string(), request.port),
Socks5Address::Domain(name) => (name.clone(), request.port),
};
match opener.open_channel(host, port).await {
Ok(mut ssh_stream) => {
let bind_addr = Socks5Address::Ipv4(std::net::Ipv4Addr::UNSPECIFIED);
let reply = Socks5Reply::success(bind_addr, 0);
reply.write_to(&mut socket).await?;
tokio::io::copy_bidirectional(&mut socket, &mut ssh_stream).await?;
Ok(())
}
Err(_) => {
send_error_reply(&mut socket, Socks5Reply::connection_refused()).await?;
Err(Socks5Error::ChannelOpenFailed)
}
}
}
async fn send_error_reply<S: AsyncRead + AsyncWrite + Unpin>(
socket: &mut S,
reply: Socks5Reply,
) -> Result<(), Socks5Error> {
reply.write_to(socket).await?;
let _ = socket.shutdown().await;
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum Socks5Error {
#[error("invalid SOCKS version: {0}")]
InvalidVersion(u8),
#[error("no acceptable auth method")]
NoAcceptableAuth,
#[error("unsupported command: {0}")]
UnsupportedCommand(u8),
#[error("channel open failed")]
ChannelOpenFailed,
#[error("io error")]
Io(#[from] std::io::Error),
}
pub struct HandleChannelOpener<H: russh::client::Handler> {
handle: Arc<Mutex<russh::client::Handle<H>>>,
}
impl<H: russh::client::Handler> HandleChannelOpener<H> {
pub fn new(handle: russh::client::Handle<H>) -> Self {
Self {
handle: Arc::new(Mutex::new(handle)),
}
}
pub fn from_arc(handle: Arc<Mutex<russh::client::Handle<H>>>) -> Self {
Self { handle }
}
}
impl<H: russh::client::Handler + Send + Sync + 'static> ChannelOpener for HandleChannelOpener<H> {
type Stream = russh::ChannelStream<russh::client::Msg>;
async fn open_channel(
&self,
host: String,
port: u16,
) -> Result<Self::Stream, ChannelOpenError> {
let handle = self.handle.lock().await;
if handle.is_closed() {
return Err(ChannelOpenError::SessionClosed);
}
let channel = handle
.channel_open_direct_tcpip(host, port as u32, "127.0.0.1", 0)
.await
.map_err(|_| ChannelOpenError::ChannelOpenFailed)?;
Ok(channel.into_stream())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt, DuplexStream};
struct MockChannelOpener {
fail: bool,
}
impl ChannelOpener for MockChannelOpener {
type Stream = DuplexStream;
async fn open_channel(
&self,
_host: String,
_port: u16,
) -> Result<Self::Stream, ChannelOpenError> {
if self.fail {
Err(ChannelOpenError::ChannelOpenFailed)
} else {
let (client, _server) = duplex(4096);
Ok(client)
}
}
}
fn build_socks5_greeting(methods: &[u8]) -> Vec<u8> {
let mut buf = vec![0x05, methods.len() as u8];
buf.extend_from_slice(methods);
buf
}
fn build_socks5_connect_ipv4(addr: [u8; 4], port: u16) -> Vec<u8> {
let mut buf = vec![0x05, 0x01, 0x00, 0x01];
buf.extend_from_slice(&addr);
buf.extend_from_slice(&port.to_be_bytes());
buf
}
fn build_socks5_connect_domain(domain: &str, port: u16) -> Vec<u8> {
let mut buf = vec![0x05, 0x01, 0x00, 0x03];
buf.push(domain.len() as u8);
buf.extend_from_slice(domain.as_bytes());
buf.extend_from_slice(&port.to_be_bytes());
buf
}
fn build_socks5_connect_ipv6(addr: [u8; 16], port: u16) -> Vec<u8> {
let mut buf = vec![0x05, 0x01, 0x00, 0x04];
buf.extend_from_slice(&addr);
buf.extend_from_slice(&port.to_be_bytes());
buf
}
async fn do_handshake(client: &mut DuplexStream) -> [u8; 2] {
client
.write_all(&build_socks5_greeting(&[0x00]))
.await
.unwrap();
client.flush().await.unwrap();
let mut resp = [0u8; 2];
client.read_exact(&mut resp).await.unwrap();
resp
}
async fn do_connect_ipv4(client: &mut DuplexStream, addr: [u8; 4], port: u16) -> Vec<u8> {
client
.write_all(&build_socks5_connect_ipv4(addr, port))
.await
.unwrap();
client.flush().await.unwrap();
let mut reply_buf = [0u8; 10];
client.read_exact(&mut reply_buf).await.unwrap();
reply_buf.to_vec()
}
#[tokio::test]
async fn handshake_no_auth_method() {
let (mut client, server) = duplex(4096);
let opener = MockChannelOpener { fail: false };
let server_handle =
tokio::spawn(async move { handle_socks5_connection(server, Arc::new(opener)).await });
let resp = do_handshake(&mut client).await;
assert_eq!(resp, [0x05, 0x00]);
let reply_buf = do_connect_ipv4(&mut client, [127, 0, 0, 1], 80).await;
assert_eq!(reply_buf[0], 0x05);
assert_eq!(reply_buf[1], 0x00);
drop(client);
let _ = server_handle.await;
}
#[tokio::test]
async fn handshake_rejects_no_acceptable_method() {
let (mut client, server) = duplex(4096);
let opener = MockChannelOpener { fail: false };
let server_handle =
tokio::spawn(async move { handle_socks5_connection(server, Arc::new(opener)).await });
client
.write_all(&build_socks5_greeting(&[0x02]))
.await
.unwrap();
client.flush().await.unwrap();
let mut resp = [0u8; 2];
client.read_exact(&mut resp).await.unwrap();
assert_eq!(resp, [0x05, 0xFF]);
drop(client);
let result = server_handle.await.unwrap();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Socks5Error::NoAcceptableAuth));
}
#[tokio::test]
async fn address_type_ipv4() {
let (mut client, server) = duplex(4096);
let opener = MockChannelOpener { fail: false };
let server_handle =
tokio::spawn(async move { handle_socks5_connection(server, Arc::new(opener)).await });
do_handshake(&mut client).await;
let reply_buf = do_connect_ipv4(&mut client, [10, 0, 0, 1], 443).await;
assert_eq!(reply_buf[1], 0x00);
drop(client);
let _ = server_handle.await;
}
#[tokio::test]
async fn address_type_domain() {
let (mut client, server) = duplex(4096);
let opener = MockChannelOpener { fail: false };
let server_handle =
tokio::spawn(async move { handle_socks5_connection(server, Arc::new(opener)).await });
do_handshake(&mut client).await;
client
.write_all(&build_socks5_connect_domain("example.com", 443))
.await
.unwrap();
client.flush().await.unwrap();
let mut reply_buf = [0u8; 10];
client.read_exact(&mut reply_buf).await.unwrap();
assert_eq!(reply_buf[1], 0x00);
drop(client);
let _ = server_handle.await;
}
#[tokio::test]
async fn address_type_ipv6() {
let (mut client, server) = duplex(4096);
let opener = MockChannelOpener { fail: false };
let server_handle =
tokio::spawn(async move { handle_socks5_connection(server, Arc::new(opener)).await });
do_handshake(&mut client).await;
let ipv6_addr = [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
client
.write_all(&build_socks5_connect_ipv6(ipv6_addr, 443))
.await
.unwrap();
client.flush().await.unwrap();
let mut reply_buf = [0u8; 10];
client.read_exact(&mut reply_buf).await.unwrap();
assert_eq!(reply_buf[0], 0x05);
assert_eq!(reply_buf[1], 0x00);
drop(client);
let _ = server_handle.await;
}
#[tokio::test]
async fn channel_open_failure_returns_socks5_error() {
let (mut client, server) = duplex(4096);
let opener = MockChannelOpener { fail: true };
let server_handle =
tokio::spawn(async move { handle_socks5_connection(server, Arc::new(opener)).await });
do_handshake(&mut client).await;
let reply_buf = do_connect_ipv4(&mut client, [10, 0, 0, 1], 80).await;
assert_eq!(reply_buf[0], 0x05);
assert_eq!(reply_buf[1], 0x05);
drop(client);
let _ = server_handle.await;
}
#[tokio::test]
async fn unsupported_command_returns_error() {
let (mut client, server) = duplex(4096);
let opener = MockChannelOpener { fail: false };
let server_handle =
tokio::spawn(async move { handle_socks5_connection(server, Arc::new(opener)).await });
do_handshake(&mut client).await;
let mut bind_req = vec![0x05, 0x02, 0x00, 0x01];
bind_req.extend_from_slice(&[127, 0, 0, 1]);
bind_req.extend_from_slice(&80u16.to_be_bytes());
client.write_all(&bind_req).await.unwrap();
client.flush().await.unwrap();
let mut reply_buf = [0u8; 10];
client.read_exact(&mut reply_buf).await.unwrap();
assert_eq!(reply_buf[1], 0x07);
drop(client);
let _ = server_handle.await;
}
#[tokio::test]
async fn bidirectional_proxy_flow() {
let (mut client_sock, server_sock) = duplex(4096);
let (ssh_client, mut ssh_server) = duplex(4096);
let ssh_stream = Arc::new(Mutex::new(Some(ssh_client)));
struct ProxyOpener {
stream: Arc<Mutex<Option<DuplexStream>>>,
}
impl ChannelOpener for ProxyOpener {
type Stream = DuplexStream;
async fn open_channel(
&self,
_host: String,
_port: u16,
) -> Result<Self::Stream, ChannelOpenError> {
self.stream
.lock()
.await
.take()
.ok_or(ChannelOpenError::ChannelOpenFailed)
}
}
let opener = ProxyOpener {
stream: Arc::clone(&ssh_stream),
};
let server_handle =
tokio::spawn(
async move { handle_socks5_connection(server_sock, Arc::new(opener)).await },
);
do_handshake(&mut client_sock).await;
let reply_buf = do_connect_ipv4(&mut client_sock, [127, 0, 0, 1], 80).await;
assert_eq!(reply_buf[1], 0x00);
let test_data = b"hello through tunnel";
client_sock.write_all(test_data).await.unwrap();
client_sock.flush().await.unwrap();
let mut received = vec![0u8; test_data.len()];
AsyncReadExt::read_exact(&mut ssh_server, &mut received)
.await
.unwrap();
assert_eq!(&received, test_data);
let echo_data = b"response from tunnel";
ssh_server.write_all(echo_data).await.unwrap();
ssh_server.flush().await.unwrap();
let mut received_back = vec![0u8; echo_data.len()];
client_sock.read_exact(&mut received_back).await.unwrap();
assert_eq!(&received_back, echo_data);
drop(client_sock);
drop(ssh_server);
let _ = server_handle.await;
}
#[tokio::test]
async fn default_listen_address() {
let opener = MockChannelOpener { fail: false };
let server = Socks5Server::new(opener);
assert_eq!(server.listen_addr(), "127.0.0.1:1080".parse().unwrap());
}
#[tokio::test]
async fn custom_listen_address() {
let opener = MockChannelOpener { fail: false };
let server = Socks5Server::with_addr(opener, "127.0.0.1:9050");
assert_eq!(server.listen_addr(), "127.0.0.1:9050".parse().unwrap());
}
}

View File

@@ -1,304 +0,0 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
#[derive(Debug, Clone, PartialEq)]
pub enum Socks5Address {
Ipv4(Ipv4Addr),
Ipv6(Ipv6Addr),
Domain(String),
}
#[derive(Debug)]
pub struct Socks5VersionMethod {
pub version: u8,
pub methods: Vec<u8>,
}
impl Socks5VersionMethod {
pub async fn read_from<R: AsyncRead + Unpin>(reader: &mut R) -> std::io::Result<Self> {
let version = reader.read_u8().await?;
let nmethods = reader.read_u8().await?;
let mut methods = vec![0u8; nmethods as usize];
reader.read_exact(&mut methods).await?;
Ok(Self { version, methods })
}
}
#[derive(Debug)]
pub struct Socks5Request {
pub version: u8,
pub command: u8,
pub address: Socks5Address,
pub port: u16,
}
impl Socks5Request {
pub async fn read_from<R: AsyncRead + Unpin>(reader: &mut R) -> std::io::Result<Self> {
let version = reader.read_u8().await?;
let command = reader.read_u8().await?;
let _rsv = reader.read_u8().await?;
let atyp = reader.read_u8().await?;
let address = match atyp {
0x01 => {
let mut octets = [0u8; 4];
reader.read_exact(&mut octets).await?;
Socks5Address::Ipv4(Ipv4Addr::from(octets))
}
0x04 => {
let mut octets = [0u8; 16];
reader.read_exact(&mut octets).await?;
Socks5Address::Ipv6(Ipv6Addr::from(octets))
}
0x03 => {
let len = reader.read_u8().await?;
let mut domain = vec![0u8; len as usize];
reader.read_exact(&mut domain).await?;
Socks5Address::Domain(String::from_utf8_lossy(&domain).into_owned())
}
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unsupported address type: {atyp}"),
))
}
};
let port = reader.read_u16().await?;
Ok(Self {
version,
command,
address,
port,
})
}
}
#[derive(Debug)]
pub struct Socks5Reply {
pub version: u8,
pub reply: u8,
pub address: Socks5Address,
pub port: u16,
}
impl Socks5Reply {
pub fn success(address: Socks5Address, port: u16) -> Self {
Self {
version: 0x05,
reply: 0x00,
address,
port,
}
}
pub fn connection_refused() -> Self {
Self {
version: 0x05,
reply: 0x05,
address: Socks5Address::Ipv4(Ipv4Addr::UNSPECIFIED),
port: 0,
}
}
pub fn command_not_supported() -> Self {
Self {
version: 0x05,
reply: 0x07,
address: Socks5Address::Ipv4(Ipv4Addr::UNSPECIFIED),
port: 0,
}
}
pub async fn write_to<W: AsyncWrite + Unpin>(&self, writer: &mut W) -> std::io::Result<()> {
writer.write_u8(self.version).await?;
writer.write_u8(self.reply).await?;
writer.write_u8(0x00).await?;
match &self.address {
Socks5Address::Ipv4(addr) => {
writer.write_u8(0x01).await?;
writer.write_all(&addr.octets()).await?;
}
Socks5Address::Ipv6(addr) => {
writer.write_u8(0x04).await?;
writer.write_all(&addr.octets()).await?;
}
Socks5Address::Domain(name) => {
writer.write_u8(0x03).await?;
writer.write_u8(name.len() as u8).await?;
writer.write_all(name.as_bytes()).await?;
}
}
writer.write_u16(self.port).await?;
writer.flush().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[tokio::test]
async fn parse_version_method_no_auth() {
let data = [0x05, 0x01, 0x00];
let mut cursor = Cursor::new(&data[..]);
let vm = Socks5VersionMethod::read_from(&mut cursor).await.unwrap();
assert_eq!(vm.version, 0x05);
assert_eq!(vm.methods, vec![0x00]);
}
#[tokio::test]
async fn parse_version_method_multiple() {
let data = [0x05, 0x02, 0x00, 0x02];
let mut cursor = Cursor::new(&data[..]);
let vm = Socks5VersionMethod::read_from(&mut cursor).await.unwrap();
assert_eq!(vm.version, 0x05);
assert_eq!(vm.methods, vec![0x00, 0x02]);
}
#[tokio::test]
async fn parse_request_ipv4() {
let mut data = vec![0x05, 0x01, 0x00, 0x01];
data.extend_from_slice(&[10, 0, 0, 1]);
data.extend_from_slice(&443u16.to_be_bytes());
let mut cursor = Cursor::new(&data[..]);
let req = Socks5Request::read_from(&mut cursor).await.unwrap();
assert_eq!(req.version, 0x05);
assert_eq!(req.command, 0x01);
assert_eq!(req.address, Socks5Address::Ipv4(Ipv4Addr::new(10, 0, 0, 1)));
assert_eq!(req.port, 443);
}
#[tokio::test]
async fn parse_request_ipv6() {
let mut data = vec![0x05, 0x01, 0x00, 0x04];
let octets: [u8; 16] = [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
data.extend_from_slice(&octets);
data.extend_from_slice(&443u16.to_be_bytes());
let mut cursor = Cursor::new(&data[..]);
let req = Socks5Request::read_from(&mut cursor).await.unwrap();
assert_eq!(req.version, 0x05);
assert_eq!(req.command, 0x01);
assert!(matches!(req.address, Socks5Address::Ipv6(_)));
assert_eq!(req.port, 443);
}
#[tokio::test]
async fn parse_request_domain() {
let domain = "example.com";
let mut data = vec![0x05, 0x01, 0x00, 0x03];
data.push(domain.len() as u8);
data.extend_from_slice(domain.as_bytes());
data.extend_from_slice(&443u16.to_be_bytes());
let mut cursor = Cursor::new(&data[..]);
let req = Socks5Request::read_from(&mut cursor).await.unwrap();
assert_eq!(req.version, 0x05);
assert_eq!(req.command, 0x01);
assert_eq!(
req.address,
Socks5Address::Domain("example.com".to_string())
);
assert_eq!(req.port, 443);
}
#[tokio::test]
async fn parse_request_unsupported_address_type() {
let data = [0x05, 0x01, 0x00, 0x05];
let mut cursor = Cursor::new(&data[..]);
let result = Socks5Request::read_from(&mut cursor).await;
assert!(result.is_err());
}
#[tokio::test]
async fn reply_success_ipv4() {
let reply = Socks5Reply::success(Socks5Address::Ipv4(Ipv4Addr::UNSPECIFIED), 0);
let mut buf = Vec::new();
reply.write_to(&mut buf).await.unwrap();
assert_eq!(buf[0], 0x05);
assert_eq!(buf[1], 0x00);
assert_eq!(buf[2], 0x00);
assert_eq!(buf[3], 0x01);
}
#[tokio::test]
async fn reply_connection_refused() {
let reply = Socks5Reply::connection_refused();
let mut buf = Vec::new();
reply.write_to(&mut buf).await.unwrap();
assert_eq!(buf[0], 0x05);
assert_eq!(buf[1], 0x05);
}
#[tokio::test]
async fn reply_command_not_supported() {
let reply = Socks5Reply::command_not_supported();
let mut buf = Vec::new();
reply.write_to(&mut buf).await.unwrap();
assert_eq!(buf[0], 0x05);
assert_eq!(buf[1], 0x07);
}
#[tokio::test]
async fn roundtrip_ipv4_reply() {
let reply = Socks5Reply::success(Socks5Address::Ipv4(Ipv4Addr::new(127, 0, 0, 1)), 1080);
let mut buf = Vec::new();
reply.write_to(&mut buf).await.unwrap();
let mut cursor = Cursor::new(&buf[..]);
let version = cursor.read_u8().await.unwrap();
let _reply_code = cursor.read_u8().await.unwrap();
let _rsv = cursor.read_u8().await.unwrap();
let atyp = cursor.read_u8().await.unwrap();
assert_eq!(version, 0x05);
assert_eq!(atyp, 0x01);
let mut octets = [0u8; 4];
cursor.read_exact(&mut octets).await.unwrap();
assert_eq!(Ipv4Addr::from(octets), Ipv4Addr::new(127, 0, 0, 1));
let port = cursor.read_u16().await.unwrap();
assert_eq!(port, 1080);
}
#[tokio::test]
async fn roundtrip_ipv6_reply() {
let addr = Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1);
let reply = Socks5Reply::success(Socks5Address::Ipv6(addr), 443);
let mut buf = Vec::new();
reply.write_to(&mut buf).await.unwrap();
let mut cursor = Cursor::new(&buf[..]);
let _version = cursor.read_u8().await.unwrap();
let _reply_code = cursor.read_u8().await.unwrap();
let _rsv = cursor.read_u8().await.unwrap();
let atyp = cursor.read_u8().await.unwrap();
assert_eq!(atyp, 0x04);
let mut octets = [0u8; 16];
cursor.read_exact(&mut octets).await.unwrap();
assert_eq!(Ipv6Addr::from(octets), addr);
let port = cursor.read_u16().await.unwrap();
assert_eq!(port, 443);
}
#[tokio::test]
async fn roundtrip_domain_reply() {
let reply = Socks5Reply::success(Socks5Address::Domain("example.com".to_string()), 8080);
let mut buf = Vec::new();
reply.write_to(&mut buf).await.unwrap();
let mut cursor = Cursor::new(&buf[..]);
let _version = cursor.read_u8().await.unwrap();
let _reply_code = cursor.read_u8().await.unwrap();
let _rsv = cursor.read_u8().await.unwrap();
let atyp = cursor.read_u8().await.unwrap();
assert_eq!(atyp, 0x03);
let len = cursor.read_u8().await.unwrap();
let mut domain = vec![0u8; len as usize];
cursor.read_exact(&mut domain).await.unwrap();
assert_eq!(String::from_utf8(domain).unwrap(), "example.com");
let port = cursor.read_u16().await.unwrap();
assert_eq!(port, 8080);
}
}

View File

@@ -0,0 +1,203 @@
//! Credential store: `CredentialStore` repo trait, `InMemoryCredentialStore`
//! default adapter, `EncryptedData` core mirror, and the shared `StoreError`.
//!
//! See `docs/architecture/crates/core/auth.md` and ADR-031 / ADR-035 for the
//! full specification. The store persists `EncryptedData` blobs keyed by
//! provider; it never decrypts (ADR-025 — the vault is the sole decryption
//! boundary).
use std::collections::HashMap;
use std::sync::RwLock;
use async_trait::async_trait;
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("backend error: {message}")]
Backend { message: String },
#[error("not found: {entity}")]
NotFound { entity: String },
#[error("serialization error: {message}")]
Serialization { message: String },
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EncryptedData {
pub key_version: u32,
pub salt: Vec<u8>,
pub iv: Vec<u8>,
pub data: Vec<u8>,
}
#[async_trait]
pub trait CredentialStore: Send + Sync {
fn get(&self, provider: &str) -> Option<EncryptedData>;
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError>;
async fn delete(&self, provider: &str) -> Result<(), StoreError>;
}
pub struct InMemoryCredentialStore {
entries: RwLock<HashMap<String, EncryptedData>>,
}
impl InMemoryCredentialStore {
pub fn new() -> Self {
Self {
entries: RwLock::new(HashMap::new()),
}
}
pub fn with_entries(entries: HashMap<String, EncryptedData>) -> Self {
Self {
entries: RwLock::new(entries),
}
}
}
impl Default for InMemoryCredentialStore {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CredentialStore for InMemoryCredentialStore {
fn get(&self, provider: &str) -> Option<EncryptedData> {
let entries = self.entries.read().unwrap_or_else(|e| e.into_inner());
entries.get(provider).cloned()
}
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError> {
let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner());
entries.insert(provider.to_string(), data.clone());
Ok(())
}
async fn delete(&self, provider: &str) -> Result<(), StoreError> {
let mut entries = self.entries.write().unwrap_or_else(|e| e.into_inner());
entries.remove(provider);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_encrypted_data() -> EncryptedData {
EncryptedData {
key_version: 2,
salt: vec![],
iv: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
data: vec![0xde, 0xad, 0xbe, 0xef],
}
}
#[tokio::test]
async fn in_memory_get_put_delete_round_trip() {
let store = InMemoryCredentialStore::new();
let data = sample_encrypted_data();
assert!(store.get("openai").is_none());
store.put("openai", &data).await.unwrap();
let retrieved = store.get("openai").expect("provider should be present");
assert_eq!(retrieved.key_version, data.key_version);
assert_eq!(retrieved.salt, data.salt);
assert_eq!(retrieved.iv, data.iv);
assert_eq!(retrieved.data, data.data);
store.delete("openai").await.unwrap();
assert!(store.get("openai").is_none());
}
#[tokio::test]
async fn in_memory_get_returns_none_for_missing_provider() {
let store = InMemoryCredentialStore::new();
assert!(store.get("never-configured").is_none());
}
#[tokio::test]
async fn in_memory_delete_missing_provider_is_ok() {
let store = InMemoryCredentialStore::new();
store.delete("absent").await.unwrap();
}
#[tokio::test]
async fn in_memory_put_replaces_existing() {
let store = InMemoryCredentialStore::new();
let first = sample_encrypted_data();
let mut second = sample_encrypted_data();
second.data = vec![0xc0, 0xff, 0xee];
store.put("anthropic", &first).await.unwrap();
store.put("anthropic", &second).await.unwrap();
let retrieved = store.get("anthropic").expect("provider should be present");
assert_eq!(retrieved.data, second.data);
}
#[tokio::test]
async fn in_memory_with_entries_seeds_store() {
let mut entries = HashMap::new();
entries.insert("github".to_string(), sample_encrypted_data());
let store = InMemoryCredentialStore::with_entries(entries);
assert!(store.get("github").is_some());
assert!(store.get("openai").is_none());
}
#[test]
fn encrypted_data_serializes_and_deserializes_round_trip() {
let data = sample_encrypted_data();
let json = serde_json::to_string(&data).expect("serialize");
let decoded: EncryptedData = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded.key_version, data.key_version);
assert_eq!(decoded.salt, data.salt);
assert_eq!(decoded.iv, data.iv);
assert_eq!(decoded.data, data.data);
}
#[test]
fn encrypted_data_round_trips_non_empty_salt() {
let data = EncryptedData {
key_version: 1,
salt: vec![0xab, 0xcd, 0xef],
iv: vec![0; 12],
data: vec![0x01, 0x02, 0x03],
};
let json = serde_json::to_string(&data).expect("serialize");
let decoded: EncryptedData = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded.salt, data.salt);
}
#[test]
fn store_error_display_formatting() {
let backend = StoreError::Backend {
message: "disk full".to_string(),
};
assert_eq!(backend.to_string(), "backend error: disk full");
let not_found = StoreError::NotFound {
entity: "openai".to_string(),
};
assert_eq!(not_found.to_string(), "not found: openai");
let serialization = StoreError::Serialization {
message: "invalid utf8".to_string(),
};
assert_eq!(
serialization.to_string(),
"serialization error: invalid utf8"
);
}
#[test]
fn store_error_is_non_exhaustive() {
let err = StoreError::Backend {
message: "x".to_string(),
};
let _ = err.to_string();
}
}

View File

@@ -1,141 +0,0 @@
use anyhow::Result;
use tokio::io::{AsyncRead, AsyncWrite, DuplexStream};
#[cfg(feature = "transport-traits")]
pub use crate::transport::{Transport, TransportAcceptor, TransportInfo, TransportKind};
#[cfg(not(feature = "transport-traits"))]
pub use local_traits::{Transport, TransportAcceptor, TransportInfo, TransportKind};
#[cfg(not(feature = "transport-traits"))]
mod local_traits {
use anyhow::Result;
use async_trait::async_trait;
use std::net::SocketAddr;
use tokio::io::{AsyncRead, AsyncWrite};
#[async_trait]
pub trait Transport: Send + Sync + 'static {
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
async fn connect(&self) -> Result<Self::Stream>;
fn describe(&self) -> String;
}
#[async_trait]
pub trait TransportAcceptor: Send + Sync + 'static {
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)>;
}
#[derive(Debug, Clone)]
pub struct TransportInfo {
pub remote_addr: Option<SocketAddr>,
pub transport_kind: TransportKind,
}
#[derive(Debug, Clone)]
pub enum TransportKind {
Tcp,
Tls { server_name: Option<String> },
Iroh { endpoint_id: String },
}
}
pub struct MockStream {
inner: DuplexStream,
}
impl MockStream {
pub fn new(inner: DuplexStream) -> Self {
Self { inner }
}
}
impl AsyncRead for MockStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
}
}
impl AsyncWrite for MockStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
std::pin::Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().inner).poll_flush(cx)
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
}
}
impl Unpin for MockStream {}
pub struct MockTransport {
buf_size: usize,
}
impl MockTransport {
pub fn new(buf_size: usize) -> Self {
Self { buf_size }
}
}
#[async_trait::async_trait]
impl Transport for MockTransport {
type Stream = MockStream;
async fn connect(&self) -> Result<Self::Stream> {
let (client, _) = tokio::io::duplex(self.buf_size);
Ok(MockStream::new(client))
}
fn describe(&self) -> String {
"mock".to_string()
}
}
pub struct MockTransportAcceptor {
buf_size: usize,
}
impl MockTransportAcceptor {
pub fn new(buf_size: usize) -> Self {
Self { buf_size }
}
}
#[async_trait::async_trait]
impl TransportAcceptor for MockTransportAcceptor {
type Stream = MockStream;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (_, server) = tokio::io::duplex(self.buf_size);
let info = TransportInfo {
remote_addr: None,
transport_kind: TransportKind::Tcp,
};
Ok((MockStream::new(server), info))
}
}
pub fn mock_pair(buf_size: usize) -> (MockStream, MockStream) {
let (client, server) = tokio::io::duplex(buf_size);
(MockStream::new(client), MockStream::new(server))
}

View File

@@ -1,352 +0,0 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use rustls::crypto::aws_lc_rs::default_provider;
use rustls::ServerConfig;
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, AcmeState, ResolvesServerCertAcme};
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor as TokioTlsAcceptor;
use tracing::{error, info};
use super::{TransportAcceptor, TransportInfo, TransportKind};
const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
#[derive(Debug, Clone)]
pub enum AcmeMode {
Domain { domain: String },
Ip,
}
pub struct AcmeCertProvider {
mode: AcmeMode,
cache_dir: Option<PathBuf>,
directory_url: String,
contact: Vec<String>,
}
impl std::fmt::Debug for AcmeCertProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcmeCertProvider")
.field("mode", &self.mode)
.field("cache_dir", &self.cache_dir)
.field("directory_url", &self.directory_url)
.field("contact", &self.contact)
.finish_non_exhaustive()
}
}
impl AcmeCertProvider {
pub fn new(mode: AcmeMode) -> Self {
Self {
mode,
cache_dir: None,
directory_url: rustls_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY.to_string(),
contact: Vec::new(),
}
}
pub fn domain(domain: impl Into<String>) -> Self {
Self::new(AcmeMode::Domain {
domain: domain.into(),
})
}
pub fn ip() -> Self {
Self::new(AcmeMode::Ip)
}
pub fn with_cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(dir.into());
self
}
pub fn with_directory(mut self, url: impl Into<String>) -> Self {
self.directory_url = url.into();
self
}
pub fn with_production_directory(mut self) -> Self {
self.directory_url = rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY.to_string();
self
}
pub fn with_contact(mut self, contact: impl Into<String>) -> Self {
self.contact.push(contact.into());
self
}
pub fn mode(&self) -> &AcmeMode {
&self.mode
}
fn build_acme_state(&self) -> (AcmeState<std::io::Error>, Arc<ResolvesServerCertAcme>) {
let domains: Vec<String> = match &self.mode {
AcmeMode::Domain { domain } => vec![domain.clone()],
AcmeMode::Ip => vec![],
};
let base_config = AcmeConfig::new(domains)
.directory(&self.directory_url)
.contact(self.contact.clone());
let state = match &self.cache_dir {
Some(cache_dir) => base_config.cache(DirCache::new(cache_dir.clone())).state(),
None => base_config
.cache(rustls_acme::caches::NoCache::default())
.state(),
};
let resolver = state.resolver();
(state, resolver)
}
pub fn build_server_config_with_resolver(
&self,
resolver: Arc<ResolvesServerCertAcme>,
) -> Result<Arc<ServerConfig>> {
let provider = default_provider().into();
let mut config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| anyhow!("failed to set protocol versions: {}", e))?
.with_no_client_auth()
.with_cert_resolver(resolver);
config.alpn_protocols.push(ACME_TLS_ALPN_NAME.to_vec());
Ok(Arc::new(config))
}
}
pub struct AcmeTlsAcceptor {
listener: TcpListener,
listen_addr: SocketAddr,
#[allow(dead_code)]
server_config: Arc<ServerConfig>,
tokio_acceptor: TokioTlsAcceptor,
}
impl AcmeTlsAcceptor {
pub async fn bind_acme(addr: SocketAddr, provider: Arc<AcmeCertProvider>) -> Result<Self> {
let (state, resolver) = provider.build_acme_state();
let server_config = provider.build_server_config_with_resolver(resolver.clone())?;
Self::spawn_state_worker(state, resolver);
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
Ok(Self {
listener,
listen_addr,
server_config,
tokio_acceptor,
})
}
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
fn spawn_state_worker(state: AcmeState<std::io::Error>, resolver: Arc<ResolvesServerCertAcme>) {
use futures::StreamExt;
let task = async move {
let mut state = state;
while let Some(event) = state.next().await {
match event {
Ok(ok) => {
if let rustls_acme::EventOk::DeployedNewCert = ok {
info!("ACME: new certificate deployed");
} else {
info!("ACME event: {:?}", ok);
}
}
Err(err) => error!("ACME event error: {:?}", err),
}
if Arc::strong_count(&resolver) == 1 {
info!("ACME resolver dropped, stopping background task");
break;
}
}
};
tokio::spawn(task);
}
}
#[async_trait::async_trait]
impl TransportAcceptor for AcmeTlsAcceptor {
type Stream = tokio_rustls::server::TlsStream<tokio::net::TcpStream>;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (tcp_stream, remote_addr) = self.listener.accept().await?;
let tls_stream = self.tokio_acceptor.accept(tcp_stream).await?;
let server_name = tls_stream.get_ref().1.server_name().map(|s| s.to_string());
let info = TransportInfo {
remote_addr: Some(remote_addr),
transport_kind: TransportKind::Tls { server_name },
};
Ok((tls_stream, info))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acme_cert_provider_domain_mode() {
let provider = AcmeCertProvider::domain("example.com");
assert!(matches!(provider.mode(), AcmeMode::Domain { .. }));
if let AcmeMode::Domain { domain } = provider.mode() {
assert_eq!(domain, "example.com");
}
}
#[test]
fn acme_cert_provider_ip_mode() {
let provider = AcmeCertProvider::ip();
assert!(matches!(provider.mode(), AcmeMode::Ip));
}
#[test]
fn acme_cert_provider_default_staging_directory() {
let provider = AcmeCertProvider::domain("example.com");
assert_eq!(
provider.directory_url,
rustls_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY
);
}
#[test]
fn acme_cert_provider_production_directory() {
let provider = AcmeCertProvider::domain("example.com").with_production_directory();
assert_eq!(
provider.directory_url,
rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
);
}
#[test]
fn acme_cert_provider_custom_directory() {
let provider =
AcmeCertProvider::domain("example.com").with_directory("https://custom.acme.dir/");
assert_eq!(provider.directory_url, "https://custom.acme.dir/");
}
#[test]
fn acme_cert_provider_with_cache_dir() {
let provider = AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/acme_cache");
assert_eq!(provider.cache_dir, Some(PathBuf::from("/tmp/acme_cache")));
}
#[test]
fn acme_cert_provider_with_contact() {
let provider =
AcmeCertProvider::domain("example.com").with_contact("mailto:admin@example.com");
assert_eq!(
provider.contact,
vec!["mailto:admin@example.com".to_string()]
);
}
#[test]
fn acme_cert_provider_build_state_domain() {
let provider = AcmeCertProvider::domain("example.com");
let (_state, resolver) = provider.build_acme_state();
assert!(Arc::strong_count(&resolver) >= 2);
}
#[test]
fn acme_cert_provider_build_state_with_cache() {
let provider = AcmeCertProvider::domain("example.com").with_cache_dir("/tmp/test_cache");
let (_state, resolver) = provider.build_acme_state();
assert!(Arc::strong_count(&resolver) >= 2);
}
#[test]
fn acme_cert_provider_build_server_config() {
let _ = default_provider().install_default();
let provider = AcmeCertProvider::domain("example.com");
let (_, resolver) = provider.build_acme_state();
let config = provider
.build_server_config_with_resolver(resolver)
.unwrap();
assert!(!config.alpn_protocols.is_empty());
assert!(config
.alpn_protocols
.iter()
.any(|p| p == ACME_TLS_ALPN_NAME));
}
#[test]
fn acme_mode_domain_debug() {
let mode = AcmeMode::Domain {
domain: "test.example.com".to_string(),
};
let debug_str = format!("{:?}", mode);
assert!(debug_str.contains("test.example.com"));
}
#[test]
fn acme_mode_ip_debug() {
let mode = AcmeMode::Ip;
let debug_str = format!("{:?}", mode);
assert!(debug_str.contains("Ip"));
}
#[test]
fn acme_cert_provider_builder_chain() {
let provider = AcmeCertProvider::domain("test.example.com")
.with_production_directory()
.with_cache_dir("/tmp/cache")
.with_contact("mailto:admin@test.example.com");
assert!(matches!(provider.mode(), AcmeMode::Domain { .. }));
assert_eq!(
provider.directory_url,
rustls_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
);
assert_eq!(provider.cache_dir, Some(PathBuf::from("/tmp/cache")));
assert_eq!(provider.contact.len(), 1);
}
#[tokio::test]
async fn acme_tls_acceptor_bind_acme() {
let _ = default_provider().install_default();
let provider = Arc::new(AcmeCertProvider::domain("example.com"));
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let acceptor = AcmeTlsAcceptor::bind_acme(addr, provider).await.unwrap();
assert_ne!(acceptor.listen_addr().port(), 0);
}
#[tokio::test]
#[ignore]
async fn acme_staging_domain_cert_provisioning() {
let _ = default_provider().install_default();
let cache_dir = tempfile::tempdir().unwrap();
let provider = Arc::new(
AcmeCertProvider::domain("acme-test.example.com")
.with_cache_dir(cache_dir.path())
.with_contact("mailto:admin@example.com"),
);
let addr: SocketAddr = "0.0.0.0:443".parse().unwrap();
let result = AcmeTlsAcceptor::bind_acme(addr, provider).await;
assert!(
result.is_ok(),
"ACME TlsAcceptor should bind: {:?}",
result.err()
);
let acceptor = result.unwrap();
assert_eq!(acceptor.listen_addr().port(), 443);
}
}

View File

@@ -1,328 +0,0 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use iroh::{
endpoint::RecvStream, node_info::NodeIdExt, Endpoint, NodeId, RelayMap, RelayMode, RelayUrl,
};
use tokio::io;
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
pub const ALPN: &[u8] = b"alknet-ssh";
const DEFAULT_RELAY_URL: &str = "https://relay.iroh.network/";
/// A client-side iroh QUIC P2P transport that connects to a remote iroh endpoint.
///
/// Connects via `Endpoint::connect(node_id, alpn)`, opens a bidirectional
/// QUIC stream with `conn.open_bi()`, and joins the halves with
/// `tokio::io::join(recv, send)` to produce a duplex stream for russh.
/// Per ADR-003, `tokio::io::join` is used instead of a custom wrapper.
///
/// Use [`IrohTransport::new`] to create a standalone endpoint, or
/// [`IrohTransport::from_endpoint`] to share an existing iroh `Endpoint`
/// with other protocol handlers (blobs, gossip, docs).
pub struct IrohTransport {
node_id: NodeId,
endpoint: Endpoint,
owned: bool,
}
impl IrohTransport {
/// Create a new iroh transport with its own dedicated endpoint.
///
/// The endpoint is created with the `alknet-ssh` ALPN and the provided
/// relay URL. Use this when alknet is the only iroh service on this node.
pub async fn new(
node_id: NodeId,
relay_url: Option<RelayUrl>,
proxy_url: Option<url::Url>,
) -> Result<Self> {
let relay_url = relay_url.unwrap_or_else(|| {
DEFAULT_RELAY_URL
.parse()
.expect("default relay URL is valid")
});
let relay_map = RelayMap::from_url(relay_url);
let mut builder = Endpoint::builder()
.relay_mode(RelayMode::Custom(relay_map))
.alpns(vec![ALPN.to_vec()]);
if let Some(ref proxy) = proxy_url {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self {
node_id,
endpoint,
owned: true,
})
}
/// Create an iroh transport using an existing shared endpoint.
///
/// The endpoint must already have the `alknet-ssh` ALPN registered
/// (typically via [`iroh::protocol::Router::builder`]). This enables
/// running alknet alongside iroh-blobs, iroh-gossip, iroh-docs, and
/// other protocol handlers on the same QUIC endpoint — one connection
/// per peer, multiplexed by ALPN.
pub fn from_endpoint(node_id: NodeId, endpoint: Endpoint) -> Self {
Self {
node_id,
endpoint,
owned: false,
}
}
pub fn endpoint_id(&self) -> String {
self.endpoint.node_id().to_z32()
}
pub fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
pub fn owned(&self) -> bool {
self.owned
}
}
#[async_trait]
impl Transport for IrohTransport {
type Stream = io::Join<RecvStream, iroh::endpoint::SendStream>;
async fn connect(&self) -> Result<Self::Stream> {
let conn = self.endpoint.connect(self.node_id, ALPN).await?;
let (send, recv) = conn.open_bi().await?;
Ok(io::join(recv, send))
}
fn describe(&self) -> String {
format!("iroh://{}", self.node_id.to_z32())
}
}
/// A server-side iroh QUIC P2P transport acceptor that listens for incoming connections.
///
/// Binds an iroh `Endpoint` with the configured relay URL and optional proxy
/// (ADR-010). Accepts incoming connections, accepts bidirectional QUIC streams,
/// and joins the halves with `tokio::io::join(recv, send)`. Exposes
/// `endpoint_id()` for CLI display of the server's z-base-32 node ID.
///
/// Use [`IrohAcceptor::bind`] to create a standalone endpoint, or
/// [`IrohAcceptor::from_endpoint`] to share an existing iroh `Endpoint`
/// with other protocol handlers (blobs, gossip, docs).
///
/// When using `from_endpoint`, the alknet-ssh ALPN must be registered
/// via an iroh `Router` that calls `Handler::accept()` on incoming
/// connections with the `alknet-ssh` ALPN, then passes the accepted
/// bidirectional stream to `russh::server::run_stream()`.
pub struct IrohAcceptor {
endpoint: Endpoint,
owned: bool,
}
impl IrohAcceptor {
/// Bind a new iroh endpoint with a dedicated `alknet-ssh` ALPN.
///
/// Use this when alknet is the only iroh service on this node.
pub async fn bind(relay_url: Option<RelayUrl>, proxy_url: Option<url::Url>) -> Result<Self> {
let relay_url = relay_url.unwrap_or_else(|| {
DEFAULT_RELAY_URL
.parse()
.expect("default relay URL is valid")
});
let relay_map = RelayMap::from_url(relay_url);
let mut builder = Endpoint::builder()
.relay_mode(RelayMode::Custom(relay_map))
.alpns(vec![ALPN.to_vec()]);
if let Some(ref proxy) = proxy_url {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self {
endpoint,
owned: true,
})
}
/// Create an iroh acceptor using an existing shared endpoint.
///
/// The endpoint must already have the `alknet-ssh` ALPN registered
/// (typically via [`iroh::protocol::Router::builder`]). When using a
/// shared endpoint, incoming connections with the `alknet-ssh` ALPN
/// are routed by the Router to a `ProtocolHandler` that this acceptor
/// does not manage — the caller is responsible for bridging the
/// Router's `accept()` callback to this acceptor's stream handling.
///
/// For the standalone case where alknet owns the endpoint, use
/// [`IrohAcceptor::bind`] instead, which handles the accept loop
/// internally.
pub fn from_endpoint(endpoint: Endpoint) -> Self {
Self {
endpoint,
owned: false,
}
}
pub fn endpoint_id(&self) -> String {
self.endpoint.node_id().to_z32()
}
pub fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
pub fn owned(&self) -> bool {
self.owned
}
}
#[async_trait]
impl TransportAcceptor for IrohAcceptor {
type Stream = io::Join<RecvStream, iroh::endpoint::SendStream>;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let incoming = self
.endpoint
.accept()
.await
.ok_or_else(|| anyhow!("endpoint closed"))?;
let conn = incoming.await?;
let node_id = conn.remote_node_id()?;
let (send, recv) = conn.accept_bi().await?;
let stream = io::join(recv, send);
let info = TransportInfo {
remote_addr: None,
transport_kind: TransportKind::Iroh {
endpoint_id: node_id.to_z32(),
},
};
Ok((stream, info))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn iroh_acceptor_bind_creates_endpoint() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let endpoint_id = acceptor.endpoint_id();
assert!(!endpoint_id.is_empty());
let parsed = NodeId::from_z32(&endpoint_id);
assert!(parsed.is_ok());
assert!(acceptor.owned());
}
#[tokio::test]
async fn iroh_acceptor_bind_with_custom_relay() {
let relay: RelayUrl = "https://relay.iroh.network/".parse().unwrap();
let acceptor = IrohAcceptor::bind(Some(relay), None).await.unwrap();
assert!(!acceptor.endpoint_id().is_empty());
assert!(acceptor.owned());
}
#[tokio::test]
async fn iroh_acceptor_from_endpoint() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let endpoint = acceptor.endpoint.clone();
let shared = IrohAcceptor::from_endpoint(endpoint);
assert_eq!(shared.endpoint_id(), acceptor.endpoint_id());
assert!(!shared.owned());
}
#[test]
fn iroh_transport_describe_format() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng).public().into();
let desc = format!("iroh://{}", node_id.to_z32());
assert!(desc.starts_with("iroh://"));
}
#[tokio::test]
async fn iroh_transport_connect_builds_endpoint() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng).public().into();
let transport = IrohTransport::new(node_id, None, None).await.unwrap();
assert!(transport.describe().starts_with("iroh://"));
assert!(!transport.endpoint_id().is_empty());
assert!(transport.owned());
}
#[tokio::test]
async fn iroh_transport_from_endpoint() {
let node_id: NodeId = iroh::SecretKey::generate(rand_core::OsRng).public().into();
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let endpoint = acceptor.endpoint.clone();
let transport = IrohTransport::from_endpoint(node_id, endpoint);
assert!(transport.describe().starts_with("iroh://"));
assert_eq!(transport.endpoint_id(), acceptor.endpoint_id());
assert!(!transport.owned());
}
#[tokio::test]
#[ignore]
async fn iroh_client_connects_to_iroh_server() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let server_node_id = acceptor.endpoint().node_id();
let transport = IrohTransport::new(server_node_id, None, None)
.await
.unwrap();
let mut addrs_watcher = acceptor.endpoint().direct_addresses();
addrs_watcher.initialized().await.unwrap();
let addr_set = addrs_watcher.get().unwrap().unwrap_or_default();
for addr in addr_set {
transport
.endpoint
.add_node_addr(iroh::NodeAddr::from_parts(
server_node_id,
None,
vec![addr.addr],
))
.unwrap();
}
let accept_handle = tokio::spawn(async move {
let (stream, info) = acceptor.accept().await.unwrap();
assert!(matches!(info.transport_kind, TransportKind::Iroh { .. }));
stream
});
let _client_stream: io::Join<RecvStream, iroh::endpoint::SendStream> =
transport.connect().await.unwrap();
let _server_stream = accept_handle.await.unwrap();
}
#[tokio::test]
#[ignore]
async fn iroh_shared_endpoint_client_connects_to_server() {
let acceptor = IrohAcceptor::bind(None, None).await.unwrap();
let server_node_id = acceptor.endpoint().node_id();
let shared_endpoint = acceptor.endpoint().clone();
let transport = IrohTransport::from_endpoint(server_node_id, shared_endpoint);
let mut addrs_watcher = acceptor.endpoint().direct_addresses();
addrs_watcher.initialized().await.unwrap();
let addr_set = addrs_watcher.get().unwrap().unwrap_or_default();
for addr in addr_set {
transport
.endpoint
.add_node_addr(iroh::NodeAddr::from_parts(
server_node_id,
None,
vec![addr.addr],
))
.unwrap();
}
let accept_handle = tokio::spawn(async move {
let (stream, info) = acceptor.accept().await.unwrap();
assert!(matches!(info.transport_kind, TransportKind::Iroh { .. }));
stream
});
let _client_stream: io::Join<RecvStream, iroh::endpoint::SendStream> =
transport.connect().await.unwrap();
let _server_stream = accept_handle.await.unwrap();
}
}

View File

@@ -1,203 +0,0 @@
//! Pluggable transport layer for Alknet.
//!
//! The transport layer produces a duplex byte stream (`AsyncRead + AsyncWrite + Unpin + Send`)
//! that SSH consumes. This is the core architectural abstraction — SSH never opens its own
//! network connections; it runs entirely over whatever stream the transport provides.
//!
//! Available transports (feature-gated):
//! - `TcpTransport` / `TcpAcceptor` — always available, direct TCP
//! - `TlsTransport` / `TlsAcceptor` — behind the `tls` feature, TCP + rustls
//! - `IrohTransport` / `IrohAcceptor` — behind the `iroh` feature, QUIC P2P via iroh
//! - `AcmeTlsAcceptor` — behind the `acme` feature, auto-provision TLS certs via Let's Encrypt
//!
//! See [ADR-001](docs/architecture/decisions/001-pluggable-transport.md) and
//! [ADR-004](docs/architecture/decisions/004-ssh-over-transport.md) for design rationale.
#[cfg(feature = "iroh")]
mod iroh_transport;
mod tcp;
#[cfg(feature = "iroh")]
pub use iroh_transport::{IrohAcceptor, IrohTransport, ALPN as IROH_ALPN};
pub use tcp::{TcpAcceptor, TcpTransport};
#[cfg(feature = "tls")]
mod tls;
#[cfg(feature = "tls")]
pub use tls::{AcmeConfig, TlsAcceptor, TlsTransport};
#[cfg(feature = "acme")]
mod acme;
#[cfg(feature = "acme")]
pub use acme::{AcmeCertProvider, AcmeMode, AcmeTlsAcceptor};
use std::net::SocketAddr;
use anyhow::Result;
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite};
/// Client-side transport trait. Produces a single duplex stream per connection.
///
/// Implementations connect to a remote endpoint and return a stream that SSH
/// runs over via `russh::client::connect_stream()`. Each call to `connect()` creates
/// a new stream — multiple sessions need multiple calls or multiple transports.
#[async_trait]
pub trait Transport: Send + Sync + 'static {
/// The duplex stream type produced by this transport.
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
/// Connect to the remote endpoint and return a duplex stream.
async fn connect(&self) -> Result<Self::Stream>;
/// Return a human-readable description of this transport for logging.
fn describe(&self) -> String;
}
/// Server-side transport acceptor. Accepts incoming connections and returns streams.
///
/// Implementations bind to a local endpoint and produce streams that SSH
/// runs over via `russh::server::run_stream()`.
#[async_trait]
pub trait TransportAcceptor: Send + Sync + 'static {
/// The duplex stream type produced by this acceptor.
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
/// Accept an incoming connection and return a duplex stream with metadata.
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)>;
}
/// Metadata about an incoming transport connection.
///
/// Carries the remote address (if available) and the kind of transport
/// used. The server handler uses this for logging and auth decisions.
/// See ADR-001 for the pluggable transport rationale and ADR-004
/// for why SSH runs entirely over the transport stream.
#[derive(Debug, Clone)]
pub struct TransportInfo {
pub remote_addr: Option<SocketAddr>,
pub transport_kind: TransportKind,
}
/// The kind of transport that produced a connection.
///
/// Each variant identifies the transport mechanism. Used by the
/// server handler for logging and authorization decisions.
/// See ADR-001 and ADR-004.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportKind {
Tcp,
Tls { server_name: Option<String> },
Iroh { endpoint_id: String },
WebTransport { server_name: Option<String> },
}
impl std::fmt::Display for TransportKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransportKind::Tcp => write!(f, "tcp"),
TransportKind::Tls { .. } => write!(f, "tls"),
TransportKind::Iroh { .. } => write!(f, "iroh"),
TransportKind::WebTransport { .. } => write!(f, "webtransport"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{duplex, DuplexStream};
struct MockTransport;
#[async_trait]
impl Transport for MockTransport {
type Stream = DuplexStream;
async fn connect(&self) -> Result<Self::Stream> {
let (stream, _) = duplex(1024);
Ok(stream)
}
fn describe(&self) -> String {
"mock".to_string()
}
}
struct MockAcceptor;
#[async_trait]
impl TransportAcceptor for MockAcceptor {
type Stream = DuplexStream;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (stream, _) = duplex(1024);
let info = TransportInfo {
remote_addr: None,
transport_kind: TransportKind::Tcp,
};
Ok((stream, info))
}
}
#[tokio::test]
async fn transport_trait_object() {
let _boxed: Box<dyn Transport<Stream = DuplexStream>> = Box::new(MockTransport);
}
#[tokio::test]
async fn transport_acceptor_trait_object() {
let _boxed: Box<dyn TransportAcceptor<Stream = DuplexStream>> = Box::new(MockAcceptor);
}
#[tokio::test]
async fn transport_connect_returns_stream() {
let t = MockTransport;
let _stream = t.connect().await.unwrap();
}
#[tokio::test]
async fn transport_describe_returns_string() {
let t = MockTransport;
assert_eq!(t.describe(), "mock");
}
#[tokio::test]
async fn acceptor_accept_returns_stream_and_info() {
let a = MockAcceptor;
let (_, info) = a.accept().await.unwrap();
assert!(info.remote_addr.is_none());
assert!(matches!(info.transport_kind, TransportKind::Tcp));
}
#[test]
fn transport_kind_variants() {
let tcp = TransportKind::Tcp;
let tls = TransportKind::Tls {
server_name: Some("example.com".to_string()),
};
let iroh = TransportKind::Iroh {
endpoint_id: "abc123".to_string(),
};
let wt = TransportKind::WebTransport {
server_name: Some("example.com".to_string()),
};
if let TransportKind::Tcp = tcp {}
if let TransportKind::Tls {
server_name: Some(name),
} = tls
{
assert_eq!(name, "example.com");
}
if let TransportKind::Iroh { endpoint_id } = iroh {
assert_eq!(endpoint_id, "abc123");
}
if let TransportKind::WebTransport { server_name } = wt {
assert_eq!(server_name, Some("example.com".to_string()));
}
}
}

View File

@@ -1,162 +0,0 @@
use std::net::SocketAddr;
use anyhow::Result;
use async_trait::async_trait;
use tokio::net::{TcpListener, TcpStream};
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
/// A TCP-based client transport that connects to a remote address.
///
/// Connects via `TcpStream::connect(addr)`. Uses tokio's default
/// connect timeout behavior: the OS controls connection timeout
/// (typically ~2 minutes on Linux via `net.ipv4.tcp_syn_retries`).
/// For custom timeouts, wrap `TcpTransport` with
/// `tokio::time::timeout(duration, transport.connect())`.
pub struct TcpTransport {
addr: SocketAddr,
}
impl TcpTransport {
pub fn new(addr: SocketAddr) -> Self {
Self { addr }
}
}
#[async_trait]
impl Transport for TcpTransport {
type Stream = TcpStream;
async fn connect(&self) -> Result<Self::Stream> {
let stream = TcpStream::connect(self.addr).await?;
Ok(stream)
}
fn describe(&self) -> String {
format!("tcp://{}", self.addr)
}
}
/// A TCP-based server transport acceptor that listens for incoming connections.
///
/// Binds via `TcpListener::bind(addr)`. Accepts connections and returns
/// the stream together with `TransportInfo` containing the remote address
/// and `TransportKind::Tcp`.
pub struct TcpAcceptor {
listener: TcpListener,
listen_addr: SocketAddr,
}
impl TcpAcceptor {
/// Bind a TCP listener on the given address.
///
/// Returns the acceptor ready to receive connections.
/// The actual bound address may differ from the requested one
/// (e.g., when binding to port 0 the OS assigns an ephemeral port).
pub async fn bind(addr: SocketAddr) -> Result<Self> {
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
Ok(Self {
listener,
listen_addr,
})
}
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
}
#[async_trait]
impl TransportAcceptor for TcpAcceptor {
type Stream = TcpStream;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (stream, remote_addr) = self.listener.accept().await?;
let info = TransportInfo {
remote_addr: Some(remote_addr),
transport_kind: TransportKind::Tcp,
};
Ok((stream, info))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::test]
async fn tcp_transport_connect_creates_stream() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TcpTransport::new(addr);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let stream = transport.connect().await.unwrap();
assert_eq!(stream.local_addr().unwrap().ip(), addr.ip());
let (_server_stream, info) = accept_handle.await.unwrap();
assert!(info.remote_addr.is_some());
assert!(matches!(info.transport_kind, TransportKind::Tcp));
}
#[tokio::test]
async fn tcp_acceptor_accept_receives_connection() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = acceptor.listen_addr();
tokio::spawn(async move {
TcpStream::connect(addr).await.unwrap();
});
let (stream, info) = acceptor.accept().await.unwrap();
assert!(info.remote_addr.is_some());
assert!(matches!(info.transport_kind, TransportKind::Tcp));
assert_eq!(
info.remote_addr.unwrap().ip(),
stream.peer_addr().unwrap().ip()
);
}
#[test]
fn tcp_transport_describe_format() {
let addr: SocketAddr = "1.2.3.4:22".parse().unwrap();
let transport = TcpTransport::new(addr);
assert_eq!(transport.describe(), "tcp://1.2.3.4:22");
}
#[tokio::test]
async fn tcp_stream_is_duplex() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = acceptor.listen_addr();
let mut client = TcpStream::connect(addr).await.unwrap();
let (mut server, _) = acceptor.accept().await.unwrap();
client.write_all(b"hello").await.unwrap();
let mut buf = [0u8; 5];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
server.write_all(b"world").await.unwrap();
let mut buf = [0u8; 5];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"world");
}
#[tokio::test]
async fn tcp_acceptor_bind_port_zero_assigns_ephemeral() {
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
assert_ne!(acceptor.listen_addr().port(), 0);
}
}

View File

@@ -1,429 +0,0 @@
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, ServerConfig};
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::{
client::TlsStream as ClientTlsStream, TlsAcceptor as TokioTlsAcceptor, TlsConnector,
};
#[cfg(feature = "acme")]
use rustls::crypto::aws_lc_rs::default_provider;
#[cfg(feature = "acme")]
use rustls_acme::ResolvesServerCertAcme;
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
#[cfg(feature = "acme")]
const ACME_TLS_ALPN_NAME: &[u8] = b"acme-tls/1";
/// A TLS-based client transport that connects to a remote address over TLS.
///
/// Wraps a TCP connection with a TLS client session via `tokio_rustls::TlsConnector`.
/// Supports insecure mode (accepts any certificate, for development) and
/// custom root CA certificates for verification. The `tls_server_name` field
/// overrides the SNI hostname sent during the TLS handshake (ADR-010).
pub struct TlsTransport {
addr: SocketAddr,
tls_server_name: Option<String>,
insecure: bool,
root_cert: Option<CertificateDer<'static>>,
}
impl TlsTransport {
pub fn new(addr: SocketAddr) -> Self {
Self {
addr,
tls_server_name: None,
insecure: false,
root_cert: None,
}
}
pub fn with_server_name(mut self, name: impl Into<String>) -> Self {
self.tls_server_name = Some(name.into());
self
}
pub fn with_insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
pub fn with_root_cert(mut self, cert: CertificateDer<'static>) -> Self {
self.root_cert = Some(cert);
self
}
fn build_client_config(&self) -> Result<ClientConfig> {
if self.insecure {
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier))
.with_no_client_auth();
return Ok(config);
}
let mut root_store = RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
if let Some(ref cert) = self.root_cert {
root_store.add(cert.clone())?;
}
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
Ok(config)
}
fn resolve_server_name(&self) -> Result<ServerName<'static>> {
let name = match &self.tls_server_name {
Some(n) => n.clone(),
None => self.addr.ip().to_string(),
};
ServerName::try_from(name.clone())
.map_err(move |e| anyhow!("invalid server name '{}': {}", name, e))
}
}
#[async_trait]
impl Transport for TlsTransport {
type Stream = ClientTlsStream<TcpStream>;
async fn connect(&self) -> Result<Self::Stream> {
let tcp_stream = TcpStream::connect(self.addr).await?;
let config = self.build_client_config()?;
let connector = TlsConnector::from(Arc::new(config));
let server_name = self.resolve_server_name()?;
let tls_stream = connector.connect(server_name, tcp_stream).await?;
Ok(tls_stream)
}
fn describe(&self) -> String {
format!("tls://{}", self.addr)
}
}
/// Stub configuration for ACME certificate provisioning (ADR-008).
/// Feature-gated behind the `acme` feature. When implemented, this will
/// hold the ACME domain and challenge responder configuration.
#[derive(Debug)]
pub struct AcmeConfig {
pub domain: String,
}
/// A TLS-based server transport acceptor that accepts TCP connections
/// and wraps them with TLS server sessions via `tokio_rustls::TlsAcceptor`.
///
/// Supports three certificate modes (ADR-008):
/// - Manual certs via `bind()` with explicit cert/key
/// - ACME certs via `bind_acme()` with an `AcmeCertProvider`
/// - The stub `AcmeConfig` parameter in `bind()` is kept for backward compat
pub struct TlsAcceptor {
listener: TcpListener,
listen_addr: SocketAddr,
#[allow(dead_code)]
server_config: Arc<ServerConfig>,
tokio_acceptor: TokioTlsAcceptor,
}
impl TlsAcceptor {
pub async fn bind(
addr: SocketAddr,
tls_certs: Vec<CertificateDer<'static>>,
tls_key: PrivateKeyDer<'static>,
_acme_config: Option<AcmeConfig>,
) -> Result<Self> {
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
let server_config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(tls_certs, tls_key)?;
let server_config = Arc::new(server_config);
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
Ok(Self {
listener,
listen_addr,
server_config,
tokio_acceptor,
})
}
#[cfg(feature = "acme")]
pub async fn bind_acme(
addr: SocketAddr,
acme_resolver: Arc<ResolvesServerCertAcme>,
) -> Result<Self> {
let listener = TcpListener::bind(addr).await?;
let listen_addr = listener.local_addr()?;
let provider = default_provider().into();
let mut server_config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| anyhow!("failed to set protocol versions: {}", e))?
.with_no_client_auth()
.with_cert_resolver(acme_resolver);
server_config
.alpn_protocols
.push(ACME_TLS_ALPN_NAME.to_vec());
let server_config = Arc::new(server_config);
let tokio_acceptor = TokioTlsAcceptor::from(server_config.clone());
Ok(Self {
listener,
listen_addr,
server_config,
tokio_acceptor,
})
}
pub fn listen_addr(&self) -> SocketAddr {
self.listen_addr
}
}
#[async_trait]
impl TransportAcceptor for TlsAcceptor {
type Stream = tokio_rustls::server::TlsStream<TcpStream>;
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)> {
let (tcp_stream, remote_addr) = self.listener.accept().await?;
let tls_stream = self.tokio_acceptor.accept(tcp_stream).await?;
let server_name = tls_stream.get_ref().1.server_name().map(|s| s.to_string());
let info = TransportInfo {
remote_addr: Some(remote_addr),
transport_kind: TransportKind::Tls { server_name },
};
Ok((tls_stream, info))
}
}
#[derive(Debug)]
struct NoVerifier;
impl ServerCertVerifier for NoVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_doc: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_doc: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use rcgen::{CertificateParams, KeyPair};
use rustls::crypto::aws_lc_rs::default_provider;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn ensure_crypto_provider() {
let _ = default_provider().install_default();
}
fn generate_self_signed_cert() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
let params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
let key_pair = KeyPair::generate().unwrap();
let cert = params.self_signed(&key_pair).unwrap();
let cert_der: CertificateDer<'static> = cert.into();
let key_der = PrivateKeyDer::Pkcs8(key_pair.serialize_der().into());
(cert_der, key_der)
}
#[test]
fn tls_transport_describe_format() {
let addr: SocketAddr = "1.2.3.4:443".parse().unwrap();
let transport = TlsTransport::new(addr).with_server_name("example.com");
assert_eq!(transport.describe(), "tls://1.2.3.4:443");
}
#[test]
fn tls_transport_describe_with_ip() {
let addr: SocketAddr = "1.2.3.4:443".parse().unwrap();
let transport = TlsTransport::new(addr);
assert_eq!(transport.describe(), "tls://1.2.3.4:443");
}
#[test]
fn tls_transport_builder_methods() {
let addr: SocketAddr = "1.2.3.4:443".parse().unwrap();
let transport = TlsTransport::new(addr)
.with_server_name("alknet.test")
.with_insecure(true);
assert_eq!(transport.tls_server_name, Some("alknet.test".to_string()));
assert!(transport.insecure);
}
#[tokio::test]
async fn tls_connect_insecure_self_signed() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TlsTransport::new(addr)
.with_server_name("localhost")
.with_insecure(true);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let mut client = transport.connect().await.unwrap();
let (mut server, info) = accept_handle.await.unwrap();
assert!(info.remote_addr.is_some());
assert!(matches!(info.transport_kind, TransportKind::Tls { .. }));
client.write_all(b"hello tls").await.unwrap();
let mut buf = [0u8; 9];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello tls");
server.write_all(b"reply").await.unwrap();
let mut buf = [0u8; 5];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"reply");
}
#[tokio::test]
async fn tls_acceptor_returns_server_name() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TlsTransport::new(addr)
.with_server_name("localhost")
.with_insecure(true);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let _client = transport.connect().await.unwrap();
let (_, info) = accept_handle.await.unwrap();
if let TransportKind::Tls { server_name } = info.transport_kind {
assert_eq!(server_name, Some("localhost".to_string()));
} else {
panic!("expected TransportKind::Tls");
}
}
#[tokio::test]
async fn tls_full_client_to_server_connection() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
let addr = acceptor.listen_addr();
let transport = TlsTransport::new(addr)
.with_server_name("localhost")
.with_insecure(true);
let accept_handle = tokio::spawn(async move { acceptor.accept().await.unwrap() });
let mut client = transport.connect().await.unwrap();
let (mut server, _info) = accept_handle.await.unwrap();
let msg = b"alknet integration test";
client.write_all(msg).await.unwrap();
let mut buf = vec![0u8; msg.len()];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf[..], msg);
let reply = b"ok";
server.write_all(reply).await.unwrap();
let mut buf = [0u8; 2];
client.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, reply);
}
#[tokio::test]
async fn tls_acceptor_bind_port_zero_assigns_ephemeral() {
ensure_crypto_provider();
let (cert_der, key_der) = generate_self_signed_cert();
let acceptor = TlsAcceptor::bind(
"127.0.0.1:0".parse().unwrap(),
vec![cert_der],
key_der,
None,
)
.await
.unwrap();
assert_ne!(acceptor.listen_addr().port(), 0);
}
#[test]
fn no_verifier_accepts_any_cert() {
let verifier = NoVerifier;
assert!(verifier.supported_verify_schemes().len() > 0);
}
}

View File

@@ -0,0 +1,882 @@
//! Core types: `ProtocolHandler`, `HandlerError`, `Connection`, `BiStream`,
//! `SendStream`, `RecvStream`, `StreamError`, `Capabilities`.
//!
//! See `docs/architecture/crates/core/core-types.md` for the full specification.
use std::collections::HashMap;
use std::io;
use std::net::SocketAddr;
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use tokio::io::{AsyncRead, AsyncWrite};
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::auth::{AuthContext, Identity};
pub struct Secret<T: Zeroize + Clone> {
inner: T,
}
impl<T: Zeroize + Clone> Secret<T> {
pub fn new(value: T) -> Self {
Self { inner: value }
}
pub fn expose_secret(&self) -> &T {
&self.inner
}
}
impl<T: Zeroize + Clone> Clone for Secret<T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<T: Zeroize + Clone> Zeroize for Secret<T> {
fn zeroize(&mut self) {
self.inner.zeroize();
}
}
impl<T: Zeroize + Clone> Drop for Secret<T> {
fn drop(&mut self) {
self.inner.zeroize();
}
}
impl<T: Zeroize + Clone> std::fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
pub struct Capabilities {
entries: HashMap<String, Secret<String>>,
}
impl Zeroize for Capabilities {
fn zeroize(&mut self) {
for (_, v) in self.entries.iter_mut() {
v.zeroize();
}
self.entries.clear();
}
}
impl ZeroizeOnDrop for Capabilities {}
impl Clone for Capabilities {
fn clone(&self) -> Self {
Self {
entries: self.entries.clone(),
}
}
}
impl Capabilities {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn with_api_key(mut self, service: &str, key: String) -> Self {
self.entries
.insert(format!("api_key:{service}"), Secret::new(key));
self
}
pub fn with_http_token(mut self, service: &str, token: String) -> Self {
self.entries
.insert(format!("http_token:{service}"), Secret::new(token));
self
}
pub fn get(&self, service: &str) -> Option<&Secret<String>> {
self.entries
.get(&format!("api_key:{service}"))
.or_else(|| self.entries.get(&format!("http_token:{service}")))
}
}
impl Default for Capabilities {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for Capabilities {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Capabilities")
.field("entries", &format!("[{} redacted]", self.entries.len()))
.finish()
}
}
#[derive(Debug, thiserror::Error)]
pub enum IdentityAlreadySet {
#[error("connection identity already set")]
AlreadySet,
}
pub enum HandlerError {
ConnectionClosed,
StreamError(io::Error),
AuthRequired,
Internal(Box<dyn std::error::Error + Send + Sync>),
}
impl std::fmt::Debug for HandlerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConnectionClosed => f.write_str("HandlerError::ConnectionClosed"),
Self::StreamError(e) => f.debug_tuple("HandlerError::StreamError").field(e).finish(),
Self::AuthRequired => f.write_str("HandlerError::AuthRequired"),
Self::Internal(e) => f.debug_tuple("HandlerError::Internal").field(e).finish(),
}
}
}
impl std::fmt::Display for HandlerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConnectionClosed => f.write_str("connection closed"),
Self::StreamError(e) => write!(f, "stream error: {e}"),
Self::AuthRequired => f.write_str("authentication required"),
Self::Internal(e) => write!(f, "internal handler error: {e}"),
}
}
}
impl std::error::Error for HandlerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::StreamError(e) => Some(e),
Self::Internal(e) => Some(e.as_ref()),
_ => None,
}
}
}
pub enum StreamError {
ConnectionClosed,
StreamClosed,
Timeout,
Internal(io::Error),
}
impl From<StreamError> for HandlerError {
fn from(e: StreamError) -> Self {
match e {
StreamError::ConnectionClosed => HandlerError::ConnectionClosed,
StreamError::StreamClosed => HandlerError::StreamError(io::Error::new(
io::ErrorKind::ConnectionReset,
"stream closed",
)),
StreamError::Timeout => HandlerError::StreamError(io::Error::new(
io::ErrorKind::TimedOut,
"stream timed out",
)),
StreamError::Internal(e) => HandlerError::StreamError(e),
}
}
}
impl std::fmt::Debug for StreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConnectionClosed => f.write_str("StreamError::ConnectionClosed"),
Self::StreamClosed => f.write_str("StreamError::StreamClosed"),
Self::Timeout => f.write_str("StreamError::Timeout"),
Self::Internal(e) => f.debug_tuple("StreamError::Internal").field(e).finish(),
}
}
}
impl std::fmt::Display for StreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConnectionClosed => f.write_str("connection closed"),
Self::StreamClosed => f.write_str("stream closed"),
Self::Timeout => f.write_str("stream timed out"),
Self::Internal(e) => write!(f, "stream error: {e}"),
}
}
}
impl std::error::Error for StreamError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Internal(e) => Some(e),
_ => None,
}
}
}
#[async_trait]
pub trait ProtocolHandler: Send + Sync + 'static {
fn alpn(&self) -> &'static [u8];
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
enum SendStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::SendStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::SendStream),
Mock(Box<dyn AsyncWrite + Send + Unpin>),
}
enum RecvStreamKind {
#[cfg(feature = "quinn")]
Quinn(quinn::RecvStream),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::RecvStream),
Mock(Box<dyn AsyncRead + Send + Unpin>),
}
pub struct SendStream {
kind: SendStreamKind,
}
pub struct RecvStream {
kind: RecvStreamKind,
}
impl SendStream {
#[cfg(feature = "quinn")]
fn from_quinn(stream: quinn::SendStream) -> Self {
Self {
kind: SendStreamKind::Quinn(stream),
}
}
#[cfg(feature = "iroh")]
fn from_iroh(stream: iroh::endpoint::SendStream) -> Self {
Self {
kind: SendStreamKind::Iroh(stream),
}
}
#[allow(dead_code)]
pub fn from_mock(stream: impl AsyncWrite + Send + Unpin + 'static) -> Self {
Self {
kind: SendStreamKind::Mock(Box::new(stream)),
}
}
}
impl RecvStream {
#[cfg(feature = "quinn")]
fn from_quinn(stream: quinn::RecvStream) -> Self {
Self {
kind: RecvStreamKind::Quinn(stream),
}
}
#[cfg(feature = "iroh")]
fn from_iroh(stream: iroh::endpoint::RecvStream) -> Self {
Self {
kind: RecvStreamKind::Iroh(stream),
}
}
#[allow(dead_code)]
pub fn from_mock(stream: impl AsyncRead + Send + Unpin + 'static) -> Self {
Self {
kind: RecvStreamKind::Mock(Box::new(stream)),
}
}
}
impl AsyncWrite for SendStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_write(std::pin::Pin::new(s), cx, buf),
SendStreamKind::Mock(s) => {
AsyncWrite::poll_write(std::pin::Pin::new(s.as_mut()), cx, buf)
}
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s), cx),
SendStreamKind::Mock(s) => AsyncWrite::poll_flush(std::pin::Pin::new(s.as_mut()), cx),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
SendStreamKind::Quinn(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
#[cfg(feature = "iroh")]
SendStreamKind::Iroh(s) => AsyncWrite::poll_shutdown(std::pin::Pin::new(s), cx),
SendStreamKind::Mock(s) => {
AsyncWrite::poll_shutdown(std::pin::Pin::new(s.as_mut()), cx)
}
}
}
}
impl AsyncRead for RecvStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
match &mut self.get_mut().kind {
#[cfg(feature = "quinn")]
RecvStreamKind::Quinn(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
#[cfg(feature = "iroh")]
RecvStreamKind::Iroh(s) => AsyncRead::poll_read(std::pin::Pin::new(s), cx, buf),
RecvStreamKind::Mock(s) => {
AsyncRead::poll_read(std::pin::Pin::new(s.as_mut()), cx, buf)
}
}
}
}
enum ConnectionKind {
#[cfg(feature = "quinn")]
Quinn(quinn::Connection),
#[cfg(feature = "iroh")]
Iroh(iroh::endpoint::Connection),
Mock(Arc<dyn MockConnection + Send + Sync>),
}
#[allow(dead_code)]
pub trait MockConnection: Send + Sync {
fn remote_alpn(&self) -> &[u8];
fn remote_addr(&self) -> Option<SocketAddr>;
fn close(&self, code: u32, reason: &str);
}
pub struct Connection {
kind: ConnectionKind,
alpn: Vec<u8>,
identity: OnceLock<Identity>,
}
impl Connection {
#[cfg(feature = "quinn")]
pub fn from_quinn(conn: quinn::Connection) -> Self {
Self::from_quinn_with_alpn(conn, Vec::new())
}
#[cfg(feature = "quinn")]
pub fn from_quinn_with_alpn(conn: quinn::Connection, alpn: Vec<u8>) -> Self {
Self {
kind: ConnectionKind::Quinn(conn),
alpn,
identity: OnceLock::new(),
}
}
#[cfg(feature = "iroh")]
pub fn from_iroh(conn: iroh::endpoint::Connection) -> Self {
let alpn = conn.alpn().unwrap_or_default();
Self {
kind: ConnectionKind::Iroh(conn),
alpn,
identity: OnceLock::new(),
}
}
#[allow(dead_code)]
pub fn from_mock(mock: Arc<dyn MockConnection + Send + Sync>) -> Self {
let alpn = mock.remote_alpn().to_vec();
Self {
kind: ConnectionKind::Mock(mock),
alpn,
identity: OnceLock::new(),
}
}
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => {
let (send, recv) = c.accept_bi().await.map_err(map_quinn_connection_error)?;
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
}
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(c) => {
let (send, recv) = c.accept_bi().await.map_err(map_iroh_connection_error)?;
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
}
ConnectionKind::Mock(_) => Err(StreamError::StreamClosed),
}
}
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError> {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => {
let (send, recv) = c.open_bi().await.map_err(map_quinn_connection_error)?;
Ok((SendStream::from_quinn(send), RecvStream::from_quinn(recv)))
}
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(c) => {
let (send, recv) = c.open_bi().await.map_err(map_iroh_connection_error)?;
Ok((SendStream::from_iroh(send), RecvStream::from_iroh(recv)))
}
ConnectionKind::Mock(_) => Err(StreamError::StreamClosed),
}
}
pub fn remote_alpn(&self) -> &[u8] {
&self.alpn
}
pub fn remote_addr(&self) -> Option<SocketAddr> {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => Some(c.remote_address()),
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(_) => None,
ConnectionKind::Mock(m) => m.remote_addr(),
}
}
pub fn close(&self, code: u32, reason: &str) {
match &self.kind {
#[cfg(feature = "quinn")]
ConnectionKind::Quinn(c) => {
let code = quinn::VarInt::from(code);
c.close(code, reason.as_bytes());
}
#[cfg(feature = "iroh")]
ConnectionKind::Iroh(c) => {
let code = iroh::endpoint::VarInt::from(code);
c.close(code, reason.as_bytes());
}
ConnectionKind::Mock(m) => m.close(code, reason),
}
}
pub fn set_identity(&self, identity: Identity) -> Result<(), IdentityAlreadySet> {
self.identity
.set(identity)
.map_err(|_| IdentityAlreadySet::AlreadySet)
}
pub fn identity(&self) -> Option<&Identity> {
self.identity.get()
}
}
#[cfg(feature = "quinn")]
fn map_quinn_connection_error(e: quinn::ConnectionError) -> StreamError {
use quinn::ConnectionError as E;
match e {
E::TimedOut => StreamError::Timeout,
E::ConnectionClosed(_) | E::ApplicationClosed(_) | E::Reset => {
StreamError::ConnectionClosed
}
other => StreamError::Internal(io::Error::other(other)),
}
}
#[cfg(feature = "iroh")]
fn map_iroh_connection_error(e: iroh::endpoint::ConnectionError) -> StreamError {
use iroh::endpoint::ConnectionError as E;
match e {
E::TimedOut => StreamError::Timeout,
E::ConnectionClosed(_) | E::ApplicationClosed(_) | E::Reset => {
StreamError::ConnectionClosed
}
other => StreamError::Internal(io::Error::other(other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
struct MockConn {
alpn: &'static [u8],
addr: Option<SocketAddr>,
closed: std::sync::Mutex<Option<(u32, String)>>,
}
#[allow(dead_code)]
impl MockConnection for MockConn {
fn remote_alpn(&self) -> &[u8] {
self.alpn
}
fn remote_addr(&self) -> Option<SocketAddr> {
self.addr
}
fn close(&self, code: u32, reason: &str) {
*self.closed.lock().unwrap() = Some((code, reason.to_string()));
}
}
fn mock_connection() -> Connection {
Connection::from_mock(Arc::new(MockConn {
alpn: b"alknet/test",
addr: Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234)),
closed: std::sync::Mutex::new(None),
}))
}
#[test]
fn capabilities_new_is_empty() {
let caps = Capabilities::new();
assert!(caps.get("google").is_none());
}
#[test]
fn capabilities_with_api_key_then_get() {
let caps = Capabilities::new().with_api_key("google", "sekrit".to_string());
let secret = caps.get("google").expect("api key present");
assert_eq!(secret.expose_secret(), "sekrit");
}
#[test]
fn capabilities_with_http_token_then_get() {
let caps = Capabilities::new().with_http_token("github", "tok".to_string());
let secret = caps.get("github").expect("http token present");
assert_eq!(secret.expose_secret(), "tok");
}
#[test]
fn capabilities_clone_preserves_entries() {
let caps = Capabilities::new().with_api_key("google", "k".to_string());
let cloned = caps.clone();
assert_eq!(
cloned.get("google").map(|s| s.expose_secret().clone()),
Some("k".to_string())
);
assert_eq!(
caps.get("google").map(|s| s.expose_secret().clone()),
Some("k".to_string())
);
}
#[test]
fn capabilities_zeroize_on_drop_clears_secret() {
let mut secret = Secret::new("sensitive".to_string());
secret.zeroize();
assert_eq!(secret.expose_secret(), "");
}
#[test]
fn capabilities_does_not_derive_serialize() {
fn assert_not_serialize<T>() {}
assert_not_serialize::<Capabilities>();
}
#[test]
fn capabilities_debug_redacts_entries() {
let caps = Capabilities::new().with_api_key("google", "sekrit".to_string());
let s = format!("{:?}", caps);
assert!(s.contains("redacted"));
assert!(!s.contains("sekrit"));
}
#[test]
fn secret_debug_redacts() {
let secret = Secret::new("hidden".to_string());
assert_eq!(format!("{:?}", secret), "[REDACTED]");
}
#[test]
fn set_identity_once_succeeds_twice_errors() {
let conn = mock_connection();
let id = Identity {
id: "alk_test".to_string(),
scopes: vec!["relay:connect".to_string()],
resources: HashMap::new(),
};
assert!(conn.set_identity(id.clone()).is_ok());
assert!(matches!(
conn.set_identity(id),
Err(IdentityAlreadySet::AlreadySet)
));
}
#[test]
fn identity_get_returns_set_value() {
let conn = mock_connection();
assert!(conn.identity().is_none());
let id = Identity {
id: "alk_test".to_string(),
scopes: vec![],
resources: HashMap::new(),
};
conn.set_identity(id.clone()).unwrap();
assert_eq!(conn.identity(), Some(&id));
}
#[test]
fn connection_remote_alpn_and_addr_from_mock() {
let conn = mock_connection();
assert_eq!(conn.remote_alpn(), b"alknet/test");
assert_eq!(
conn.remote_addr(),
Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234))
);
}
#[test]
fn stream_error_maps_to_handler_error() {
assert!(matches!(
HandlerError::from(StreamError::ConnectionClosed),
HandlerError::ConnectionClosed
));
match HandlerError::from(StreamError::StreamClosed) {
HandlerError::StreamError(e) => assert_eq!(e.kind(), io::ErrorKind::ConnectionReset),
other => panic!("expected StreamError, got {other:?}"),
}
match HandlerError::from(StreamError::Timeout) {
HandlerError::StreamError(e) => assert_eq!(e.kind(), io::ErrorKind::TimedOut),
other => panic!("expected StreamError, got {other:?}"),
}
match HandlerError::from(StreamError::Internal(io::Error::other("x"))) {
HandlerError::StreamError(e) => assert_eq!(e.kind(), io::ErrorKind::Other),
other => panic!("expected StreamError, got {other:?}"),
}
}
#[test]
fn handler_error_auth_required_constructible() {
let e = HandlerError::AuthRequired;
assert_eq!(format!("{e}"), "authentication required");
}
// --- HandlerError / StreamError Debug + Display + source ---------------
#[test]
fn handler_error_debug_covers_all_variants() {
assert_eq!(
format!("{:?}", HandlerError::ConnectionClosed),
"HandlerError::ConnectionClosed"
);
let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "boom");
let dbg = format!("{:?}", HandlerError::StreamError(io_err));
assert!(dbg.contains("HandlerError::StreamError"));
assert_eq!(
format!("{:?}", HandlerError::AuthRequired),
"HandlerError::AuthRequired"
);
let inner: Box<dyn std::error::Error + Send + Sync> = "oops".into();
let dbg = format!("{:?}", HandlerError::Internal(inner));
assert!(dbg.contains("HandlerError::Internal"));
}
#[test]
fn handler_error_display_covers_all_variants() {
assert_eq!(
format!("{}", HandlerError::ConnectionClosed),
"connection closed"
);
let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "boom");
let s = format!("{}", HandlerError::StreamError(io_err));
assert!(s.starts_with("stream error: "));
assert_eq!(
format!("{}", HandlerError::AuthRequired),
"authentication required"
);
let inner: Box<dyn std::error::Error + Send + Sync> = "oops".into();
assert_eq!(
format!("{}", HandlerError::Internal(inner)),
"internal handler error: oops"
);
}
#[test]
fn handler_error_source_covers_all_variants() {
use std::error::Error;
assert!(HandlerError::ConnectionClosed.source().is_none());
assert!(HandlerError::AuthRequired.source().is_none());
let stream_err =
HandlerError::StreamError(io::Error::new(io::ErrorKind::BrokenPipe, "boom"));
assert!(
stream_err.source().is_some(),
"StreamError must expose its io::Error as source"
);
let internal_inner: Box<dyn std::error::Error + Send + Sync> = "boom".into();
let internal_err = HandlerError::Internal(internal_inner);
assert!(
internal_err.source().is_some(),
"Internal must expose its inner error as source"
);
}
#[test]
fn stream_error_debug_covers_all_variants() {
assert_eq!(
format!("{:?}", StreamError::ConnectionClosed),
"StreamError::ConnectionClosed"
);
assert_eq!(
format!("{:?}", StreamError::StreamClosed),
"StreamError::StreamClosed"
);
assert_eq!(
format!("{:?}", StreamError::Timeout),
"StreamError::Timeout"
);
let dbg = format!("{:?}", StreamError::Internal(io::Error::other("x")));
assert!(dbg.contains("StreamError::Internal"));
}
#[test]
fn stream_error_display_covers_all_variants() {
assert_eq!(
format!("{}", StreamError::ConnectionClosed),
"connection closed"
);
assert_eq!(format!("{}", StreamError::StreamClosed), "stream closed");
assert_eq!(format!("{}", StreamError::Timeout), "stream timed out");
assert_eq!(
format!("{}", StreamError::Internal(io::Error::other("boom"))),
"stream error: boom"
);
}
#[test]
fn stream_error_source_covers_all_variants() {
use std::error::Error;
assert!(StreamError::ConnectionClosed.source().is_none());
assert!(StreamError::StreamClosed.source().is_none());
assert!(StreamError::Timeout.source().is_none());
let internal = StreamError::Internal(io::Error::other("x"));
assert!(
internal.source().is_some(),
"Internal must expose its io::Error as source"
);
}
// --- map_*_connection_error -------------------------------------------
#[cfg(feature = "quinn")]
#[test]
fn map_quinn_connection_error_timed_out_maps_to_timeout() {
assert!(matches!(
map_quinn_connection_error(quinn::ConnectionError::TimedOut),
StreamError::Timeout
));
}
#[cfg(feature = "quinn")]
#[test]
fn map_quinn_connection_error_reset_maps_to_connection_closed() {
assert!(matches!(
map_quinn_connection_error(quinn::ConnectionError::Reset),
StreamError::ConnectionClosed
));
}
#[cfg(feature = "quinn")]
#[test]
fn map_quinn_connection_error_application_closed_maps_to_connection_closed() {
use bytes::Bytes;
let close = quinn::ConnectionError::ApplicationClosed(quinn::ApplicationClose {
error_code: quinn::VarInt::from_u32(1),
reason: Bytes::new(),
});
assert!(matches!(
map_quinn_connection_error(close),
StreamError::ConnectionClosed
));
}
#[cfg(feature = "quinn")]
#[test]
fn map_quinn_connection_error_other_maps_to_internal() {
let other = quinn::ConnectionError::VersionMismatch;
match map_quinn_connection_error(other) {
StreamError::Internal(e) => assert_eq!(e.kind(), io::ErrorKind::Other),
other => panic!("expected StreamError::Internal, got {other:?}"),
}
}
#[cfg(feature = "iroh")]
#[test]
fn map_iroh_connection_error_timed_out_maps_to_timeout() {
assert!(matches!(
map_iroh_connection_error(iroh::endpoint::ConnectionError::TimedOut),
StreamError::Timeout
));
}
#[cfg(feature = "iroh")]
#[test]
fn map_iroh_connection_error_reset_maps_to_connection_closed() {
assert!(matches!(
map_iroh_connection_error(iroh::endpoint::ConnectionError::Reset),
StreamError::ConnectionClosed
));
}
#[cfg(feature = "iroh")]
#[test]
fn map_iroh_connection_error_application_closed_maps_to_connection_closed() {
use bytes::Bytes;
let close =
iroh::endpoint::ConnectionError::ApplicationClosed(iroh::endpoint::ApplicationClose {
error_code: iroh::endpoint::VarInt::from_u32(1),
reason: Bytes::new(),
});
assert!(matches!(
map_iroh_connection_error(close),
StreamError::ConnectionClosed
));
}
#[cfg(feature = "iroh")]
#[test]
fn map_iroh_connection_error_other_maps_to_internal() {
let other = iroh::endpoint::ConnectionError::VersionMismatch;
match map_iroh_connection_error(other) {
StreamError::Internal(e) => assert_eq!(e.kind(), io::ErrorKind::Other),
other => panic!("expected StreamError::Internal, got {other:?}"),
}
}
// --- Capabilities zeroize + default -----------------------------------
#[test]
fn capabilities_default_is_empty() {
let caps = Capabilities::default();
assert!(caps.get("anything").is_none());
}
#[test]
fn capabilities_zeroize_clears_entries() {
let mut caps = Capabilities::new()
.with_api_key("svc-a", "k1".to_string())
.with_http_token("svc-b", "t1".to_string());
assert!(caps.get("svc-a").is_some());
assert!(caps.get("svc-b").is_some());
caps.zeroize();
assert!(caps.get("svc-a").is_none());
assert!(caps.get("svc-b").is_none());
}
}

View File

@@ -1,2 +0,0 @@
#[tokio::test]
async fn auth_placeholder() {}

View File

@@ -1,2 +0,0 @@
#[tokio::test]
async fn client_placeholder() {}

View File

@@ -1,2 +0,0 @@
#[tokio::test]
async fn server_placeholder() {}

View File

@@ -1,28 +0,0 @@
use alknet_core::testutil::{
mock_pair, MockTransport, MockTransportAcceptor, Transport, TransportAcceptor,
};
#[tokio::test]
async fn mock_transport_connect() {
let transport = MockTransport::new(1024);
let stream = transport.connect().await.unwrap();
drop(stream);
}
#[tokio::test]
async fn mock_transport_acceptor_accept() {
let acceptor = MockTransportAcceptor::new(1024);
let (stream, info) = acceptor.accept().await.unwrap();
drop(stream);
drop(info);
}
#[tokio::test]
async fn mock_pair_communicates() {
let (mut client, mut server) = mock_pair(1024);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
client.write_all(b"hello").await.unwrap();
let mut buf = [0u8; 5];
server.read_exact(&mut buf).await.unwrap();
assert_eq!(&buf, b"hello");
}

View File

@@ -0,0 +1,49 @@
[package]
name = "alknet-http"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "HTTP interface for alknet: serves HTTP/1.1 + HTTP/2 on standard ALPNs (with WebSocket upgrade for browser bidirectional access) and hosts the HTTP-backed call-protocol adapters"
repository.workspace = true
[lib]
name = "alknet_http"
[features]
default = ["h2", "http1"]
mcp = ["dep:rmcp"]
h2 = ["dep:hyper", "hyper-util/http2", "hyper/http2"]
http1 = ["dep:hyper", "hyper-util/http1", "hyper/http1"]
[dependencies]
alknet-core = { path = "../alknet-core" }
alknet-call = { path = "../alknet-call" }
arc-swap = "1"
axum = { version = "0.8", features = ["ws"] }
hyper = { version = "1", optional = true, features = ["server"] }
hyper-util = { version = "0.1", features = ["server", "service", "tokio"] }
httpdate = "1"
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
reqwest-middleware = "0.5"
reqwest-retry = "0.9"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
tracing = "0.1"
thiserror = "2"
uuid = { version = "1", features = ["v4"] }
futures = "0.3"
openapiv3 = "2"
http = "1"
url = "2"
rmcp = { version = "1.8", optional = true, default-features = false, features = [
"client",
"server",
"transport-streamable-http-client-reqwest",
"transport-streamable-http-server",
] }
[dev-dependencies]
http-body-util = "0.1"
tower = { version = "0.5", features = ["util"] }

View File

@@ -0,0 +1,315 @@
//! `from_mcp`: discover remote MCP tools over streamable HTTP and register
//! each as a `HandlerRegistration` bundle with a forwarding handler that calls
//! the remote tool via `tools/call`.
//!
//! Streamable HTTP only (ADR-037 — stdio is not built). Feature-gated behind
//! `mcp`. The forwarding handler reads the bearer token from
//! `OperationContext.capabilities` (ADR-014 no-env-vars), not `std::env::var`.
//! Provenance is `FromMCP` (leaf — `composition_authority: None`,
//! `scoped_env: None`, `Internal` by default — ADR-015/022). See
//! `docs/architecture/crates/http/http-mcp.md`.
use alknet_call::client::{AdapterError, OperationAdapter};
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
use alknet_call::registry::context::OperationContext;
use alknet_call::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
};
use alknet_call::registry::spec::{
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
};
use alknet_core::types::Capabilities;
use rmcp::model::{
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientInfo, Content, Implementation,
JsonObject, Tool,
};
use rmcp::service::RoleClient;
use rmcp::transport::{
streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport,
};
use rmcp::{Peer, ServiceExt};
use serde_json::{Map, Value};
const MCP_CAPABILITY_KEY: &str = "mcp";
pub struct FromMCP {
endpoint: String,
auth_token: Option<String>,
namespace: String,
}
impl FromMCP {
pub fn new(endpoint: impl Into<String>, namespace: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
auth_token: None,
namespace: namespace.into(),
}
}
pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(token.into());
self
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn namespace(&self) -> &str {
&self.namespace
}
pub fn auth_token(&self) -> Option<&str> {
self.auth_token.as_deref()
}
}
#[async_trait::async_trait]
impl OperationAdapter for FromMCP {
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
let mut config = StreamableHttpClientTransportConfig::with_uri(self.endpoint.clone());
if let Some(token) = &self.auth_token {
config = config.auth_header(token.clone());
}
let transport = StreamableHttpClientTransport::from_config(config);
let client_info = ClientInfo::new(
ClientCapabilities::default(),
Implementation::new("alknet-from-mcp", env!("CARGO_PKG_VERSION")),
);
let running = client_info
.serve(transport)
.await
.map_err(|e| classify_init_error(&e))?;
let peer: Peer<RoleClient> = running.peer().clone();
let tools = peer.list_tools(Default::default()).await.map_err(|e| {
AdapterError::DiscoveryFailed {
message: format!("tools/list failed: {e}"),
}
})?;
let bundles = tools
.tools
.into_iter()
.map(|tool| build_registration(&peer, &self.namespace, self.auth_token.clone(), tool))
.collect::<Vec<_>>();
std::mem::forget(running);
Ok(bundles)
}
}
fn classify_init_error(e: &rmcp::service::ClientInitializeError) -> AdapterError {
use rmcp::service::ClientInitializeError as E;
match e {
E::TransportError { error, .. } => {
let msg = format!("{error:?}");
if msg.contains("401")
|| msg.contains("Unauthorized")
|| msg.contains("AuthRequired")
|| msg.contains("AuthRequired(")
{
AdapterError::Unauthorized { message: msg }
} else {
AdapterError::DiscoveryFailed { message: msg }
}
}
other => AdapterError::DiscoveryFailed {
message: format!("initialize failed: {other}"),
},
}
}
fn build_registration(
peer: &Peer<RoleClient>,
namespace: &str,
auth_token: Option<String>,
tool: Tool,
) -> HandlerRegistration {
let spec = build_spec(&tool, namespace);
let caps = capabilities_for(auth_token);
let tool_name = tool.name.to_string();
let peer_clone = peer.clone();
let handler = make_handler(move |input: Value, context: OperationContext| {
let peer = peer_clone.clone();
let tool_name = tool_name.clone();
async move {
let request_id = context.request_id.clone();
let _token_present = context
.capabilities
.get(MCP_CAPABILITY_KEY)
.map(|s| s.expose_secret().len());
let arguments = value_to_json_object(input);
let params = CallToolRequestParams::new(tool_name.clone()).with_arguments(arguments);
let result = match peer.call_tool(params).await {
Ok(r) => r,
Err(e) => {
let message = format!("tools/call failed: {e}");
return ResponseEnvelope::error(request_id, CallError::internal(message));
}
};
map_call_tool_result(result, request_id)
}
});
HandlerRegistration::new(
spec,
HandlerKind::Once(handler),
OperationProvenance::FromMCP,
None,
None,
caps,
)
}
pub(crate) fn build_spec(tool: &Tool, namespace: &str) -> OperationSpec {
let tool_name = tool.name.to_string();
let op_name = format!("{namespace}/{tool_name}");
let input_schema = json_object_to_value(tool.input_schema.as_ref().clone());
let output_schema = output_schema_for(tool);
let error_schemas = error_schemas_for(tool);
OperationSpec::new(
op_name,
OperationType::Mutation,
Visibility::Internal,
input_schema,
output_schema,
error_schemas,
AccessControl::default(),
)
}
pub(crate) fn map_call_tool_result(result: CallToolResult, request_id: String) -> ResponseEnvelope {
if result.is_error == Some(true) {
let details = content_blocks_to_value(&result.content);
let message = if result.content.is_empty() {
"MCP tool returned isError with no content".to_string()
} else {
"MCP tool returned isError".to_string()
};
let mut err = CallError::new("MCP_TOOL_ERROR", message, false);
if details != Value::Null {
err = err.with_details(details);
}
return ResponseEnvelope::error(request_id, err);
}
if let Some(structured) = result.structured_content {
return ResponseEnvelope::ok(request_id, structured);
}
let mapped = content_blocks_to_value(&result.content);
ResponseEnvelope::ok(request_id, mapped)
}
pub(crate) fn output_schema_for(tool: &Tool) -> Value {
if let Some(schema) = &tool.output_schema {
json_object_to_value(schema.as_ref().clone())
} else {
content_block_union_schema()
}
}
pub(crate) fn content_block_union_schema() -> Value {
serde_json::json!({
"type": "array",
"description": "MCP ContentBlock union (text | image | audio | resource | resource_link)",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["text"] },
"text": { "type": "string" }
},
"required": ["type", "text"]
},
{
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["image"] },
"data": { "type": "string" },
"mimeType": { "type": "string" }
},
"required": ["type", "data", "mimeType"]
},
{
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["audio"] },
"data": { "type": "string" },
"mimeType": { "type": "string" }
},
"required": ["type", "data", "mimeType"]
},
{
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["resource"] },
"resource": { "type": "object" }
},
"required": ["type", "resource"]
},
{
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["resource_link"] },
"uri": { "type": "string" },
"name": { "type": "string" }
},
"required": ["type", "uri", "name"]
}
]
}
})
}
pub(crate) fn content_blocks_to_value(blocks: &[Content]) -> Value {
let mapped: Vec<Value> = blocks
.iter()
.map(|block| serde_json::to_value(block).unwrap_or(Value::Null))
.collect();
Value::Array(mapped)
}
fn error_schemas_for(tool: &Tool) -> Vec<ErrorDefinition> {
vec![ErrorDefinition {
code: "MCP_TOOL_ERROR".to_string(),
description: format!("MCP tool '{}' reported an error (isError)", tool.name),
schema: serde_json::json!({
"type": "array",
"description": "MCP error content blocks",
"items": content_block_union_schema()
}),
http_status: None,
}]
}
fn capabilities_for(auth_token: Option<String>) -> Capabilities {
match auth_token {
Some(token) => Capabilities::new().with_http_token(MCP_CAPABILITY_KEY, token),
None => Capabilities::new(),
}
}
fn value_to_json_object(value: Value) -> Map<String, Value> {
match value {
Value::Object(map) => map,
other => {
let mut map = Map::new();
map.insert("value".to_string(), other);
map
}
}
}
fn json_object_to_value(map: JsonObject) -> Value {
Value::Object(map)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,257 @@
use super::*;
use alknet_call::registry::spec::Visibility;
use rmcp::model::{CallToolResult, Content, Tool};
fn make_tool(name: &str, input: Value, output: Option<Value>) -> Tool {
let input_map = match input {
Value::Object(m) => m,
_ => serde_json::Map::new(),
};
let mut tool = Tool::new_with_raw(
name.to_string(),
Some("test tool".into()),
std::sync::Arc::new(input_map),
);
if let Some(out) = output {
let out_map = match out {
Value::Object(m) => m,
_ => serde_json::Map::new(),
};
tool = tool.with_raw_output_schema(std::sync::Arc::new(out_map));
}
tool
}
fn call_tool_result(
content: Vec<Content>,
structured: Option<Value>,
is_error: Option<bool>,
) -> CallToolResult {
let json = serde_json::json!({
"content": content,
"structuredContent": structured,
"isError": is_error,
});
serde_json::from_value(json).expect("CallToolResult deserializes")
}
#[test]
fn struct_holds_endpoint_auth_token_namespace() {
let adapter = FromMCP::new("http://localhost:8000/mcp", "weather");
assert_eq!(adapter.endpoint(), "http://localhost:8000/mcp");
assert_eq!(adapter.namespace(), "weather");
assert_eq!(adapter.auth_token(), None);
let with_token = adapter.with_auth_token("sekrit");
assert_eq!(with_token.auth_token(), Some("sekrit"));
}
#[test]
fn output_schema_present_uses_declared_schema() {
let declared = serde_json::json!({
"type": "object",
"properties": { "temperature": { "type": "number" } }
});
let tool = make_tool("get_weather", serde_json::json!({}), Some(declared.clone()));
let schema = output_schema_for(&tool);
assert_eq!(schema, declared);
}
#[test]
fn output_schema_absent_uses_content_block_union() {
let tool = make_tool("legacy_tool", serde_json::json!({}), None);
let schema = output_schema_for(&tool);
assert_eq!(schema, content_block_union_schema());
assert_eq!(schema["type"], "array");
}
#[test]
fn content_block_union_schema_has_all_five_variants() {
let schema = content_block_union_schema();
let one_of = schema["items"]["oneOf"].as_array().expect("oneOf array");
let variants: Vec<&str> = one_of
.iter()
.filter_map(|v| v["properties"]["type"]["enum"][0].as_str())
.collect();
assert!(variants.contains(&"text"));
assert!(variants.contains(&"image"));
assert!(variants.contains(&"audio"));
assert!(variants.contains(&"resource"));
assert!(variants.contains(&"resource_link"));
}
#[test]
fn map_structured_content_present_used_as_result() {
let result = CallToolResult::structured(serde_json::json!({ "temperature": 22.5 }));
let response = map_call_tool_result(result, "req-1".to_string());
assert_eq!(response.request_id, "req-1");
match response.result {
Ok(v) => assert_eq!(v, serde_json::json!({ "temperature": 22.5 })),
Err(e) => panic!("expected Ok, got Err: {e:?}"),
}
}
#[test]
fn map_structured_content_absent_maps_content_blocks() {
let result = CallToolResult::success(vec![
Content::text("hello world"),
Content::image("base64data", "image/png"),
]);
let response = map_call_tool_result(result, "req-2".to_string());
match response.result {
Ok(Value::Array(blocks)) => {
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "hello world");
assert_eq!(blocks[1]["type"], "image");
assert_eq!(blocks[1]["data"], "base64data");
}
other => panic!("expected array, got {other:?}"),
}
}
#[test]
fn map_single_text_block_carried_as_content_block_not_json_parsed() {
let result = CallToolResult::success(vec![Content::text(r#"{"key":"value"}"#)]);
let response = map_call_tool_result(result, "req-3".to_string());
match response.result {
Ok(Value::Array(blocks)) => {
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], r#"{"key":"value"}"#);
}
other => panic!("expected array (not JSON-parsed), got {other:?}"),
}
}
#[test]
fn map_is_error_true_returns_call_error() {
let result = CallToolResult::error(vec![Content::text("something went wrong")]);
let response = map_call_tool_result(result, "req-4".to_string());
match response.result {
Err(e) => {
assert_eq!(e.code, "MCP_TOOL_ERROR");
assert!(!e.retryable);
assert!(e.message.contains("isError"));
let details = e.details.expect("details present");
let blocks = details.as_array().expect("details is array");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["text"], "something went wrong");
}
other => panic!("expected Err, got {other:?}"),
}
}
#[test]
fn map_is_error_true_with_no_content_still_errors() {
let result = call_tool_result(vec![], None, Some(true));
let response = map_call_tool_result(result, "req-5".to_string());
match response.result {
Err(e) => {
assert_eq!(e.code, "MCP_TOOL_ERROR");
assert!(e.message.contains("no content"));
}
other => panic!("expected Err, got {other:?}"),
}
}
#[test]
fn map_empty_success_returns_empty_array() {
let result = call_tool_result(vec![], None, Some(false));
let response = map_call_tool_result(result, "req-6".to_string());
match response.result {
Ok(Value::Array(blocks)) => assert!(blocks.is_empty()),
other => panic!("expected empty array, got {other:?}"),
}
}
#[test]
fn map_structured_content_preferred_over_content_blocks() {
let result = call_tool_result(
vec![Content::text("ignored text")],
Some(serde_json::json!({ "structured": true })),
Some(false),
);
let response = map_call_tool_result(result, "req-7".to_string());
match response.result {
Ok(v) => assert_eq!(v, serde_json::json!({ "structured": true })),
other => panic!("expected structured content, got {other:?}"),
}
}
#[test]
fn error_schemas_for_tool_yields_mcp_tool_error() {
let tool = make_tool("weather", serde_json::json!({}), None);
let errors = error_schemas_for(&tool);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, "MCP_TOOL_ERROR");
assert!(errors[0].description.contains("weather"));
assert!(errors[0].description.contains("isError"));
assert!(errors[0].schema["type"] == "array");
}
#[test]
fn capabilities_for_token_injects_http_token() {
let caps = capabilities_for(Some("tok-123".to_string()));
let secret = caps.get(MCP_CAPABILITY_KEY).expect("token present");
assert_eq!(secret.expose_secret(), "tok-123");
}
#[test]
fn capabilities_for_none_yields_empty() {
let caps = capabilities_for(None);
assert!(caps.get(MCP_CAPABILITY_KEY).is_none());
}
#[test]
fn build_spec_output_schema_present_shape() {
let tool = make_tool(
"get_weather",
serde_json::json!({ "type": "object", "properties": { "city": { "type": "string" } } }),
Some(
serde_json::json!({ "type": "object", "properties": { "temperature": { "type": "number" } } }),
),
);
let spec = build_spec(&tool, "weather");
assert_eq!(spec.name, "weather/get_weather");
assert_eq!(spec.namespace, "weather");
assert_eq!(spec.op_type, OperationType::Mutation);
assert_eq!(spec.visibility, Visibility::Internal);
assert_eq!(spec.input_schema["type"], "object");
assert_eq!(spec.input_schema["properties"]["city"]["type"], "string");
assert_eq!(spec.output_schema["type"], "object");
assert_eq!(
spec.output_schema["properties"]["temperature"]["type"],
"number"
);
assert_eq!(spec.error_schemas.len(), 1);
assert_eq!(spec.error_schemas[0].code, "MCP_TOOL_ERROR");
assert!(spec.access_control == AccessControl::default());
}
#[test]
fn build_spec_output_schema_absent_uses_union() {
let tool = make_tool("legacy", serde_json::json!({}), None);
let spec = build_spec(&tool, "legacy");
assert_eq!(spec.output_schema, content_block_union_schema());
}
#[test]
fn build_spec_name_with_prefix_when_namespace_set() {
let tool = make_tool("search", serde_json::json!({}), None);
let spec = build_spec(&tool, "tools");
assert_eq!(spec.name, "tools/search");
assert_eq!(spec.namespace, "tools");
}
#[test]
fn no_env_vars_in_capability_key_constant() {
assert_eq!(MCP_CAPABILITY_KEY, "mcp");
}
#[tokio::test]
async fn forwarding_handler_reads_capabilities_not_env_vars() {
let adapter = FromMCP::new("http://127.0.0.1:1/mcp", "ns");
let _ = adapter.auth_token();
assert!(adapter.auth_token().is_none());
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
//! HTTP-backed call-protocol adapters: `from_openapi`, `to_openapi`,
//! `from_mcp`, `to_mcp`.
//!
//! `from_openapi`/`from_mcp` are the no-env-vars credential injection point
//! (ADR-014); `to_openapi`/`to_mcp` are projections of the local registry
//! (ADR-017). `from_mcp`/`to_mcp` are feature-gated behind `mcp`
//! (streamable HTTP only — ADR-037). See
//! `docs/architecture/crates/http/http-adapters.md` and
//! `docs/architecture/crates/http/http-mcp.md`.
pub mod from_openapi;
#[cfg(feature = "mcp")]
pub mod from_mcp;
#[cfg(feature = "mcp")]
pub mod to_mcp;
pub mod to_openapi;
pub use from_openapi::{FromOpenAPI, HttpAuthScheme, HttpServiceConfig, OpenAPISpec};
pub use to_openapi::to_openapi;
#[cfg(feature = "mcp")]
pub use from_mcp::FromMCP;
#[cfg(feature = "mcp")]
pub use to_mcp::{to_mcp_service, ToMcpGateway, ToMcpService};

View File

@@ -0,0 +1,952 @@
//! `to_mcp`: 4-tool gateway projection over the local operation registry,
//! exposed to external MCP clients (editors, AI tools) via rmcp's
//! `StreamableHttpService` nested into the axum `Router` at `/mcp`.
//!
//! This is the tool-gateway pattern (ADR-041): the LLM gets a fixed set of
//! meta-tools (`search`, `schema`, `call`, `batch`) and discovers operations
//! on demand — not one MCP tool per registry operation. `Subscription` ops
//! are excluded from `search` and cannot be invoked via `call` (MCP tool
//! calls are request/response — ADR-041 §2).
//!
//! `to_mcp` is a pure projection (ADR-017 §5): it consumes the registry and
//! does not produce entries for it. It is not an `OperationAdapter`. The
//! shared dispatch spine (`GatewayDispatch`) is used for the `call` tool; the
//! `ResponseEnvelope` → `CallToolResult` mapping is `to_mcp`-specific.
//!
//! Bearer auth is the shared `bearer_auth_middleware`, applied as an axum
//! layer *around* the nested `StreamableHttpService` (research §4.4 — the rmcp
//! `simple_auth_streamhttp.rs` example shows the pattern). The resolved
//! `Identity` is stashed by the middleware into `http::request::Parts`'s
//! extensions; rmcp injects `Parts` into the `RequestContext<RoleServer>`
//! extensions, so `call_tool` reads it back via
//! `context.extensions.get::<http::request::Parts>()` (research §6 #2 — the
//! load-bearing identity-survives-the-rmcp-framing assumption).
//!
//! Streamable HTTP only (ADR-037 — stdio is not built). Feature-gated behind
//! `mcp`. See `docs/architecture/crates/http/http-mcp.md`.
use std::borrow::Cow;
use std::sync::Arc;
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
use alknet_core::auth::Identity;
use rmcp::model::{
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::transport::{
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
StreamableHttpServerConfig,
};
use serde_json::{Map, Value};
use crate::gateway::GatewayDispatch;
const TOOL_SEARCH: &str = "search";
const TOOL_SCHEMA: &str = "schema";
const TOOL_CALL: &str = "call";
const TOOL_BATCH: &str = "batch";
const OP_SERVICES_LIST: &str = "services/list";
const OP_SERVICES_SCHEMA: &str = "services/schema";
fn search_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Optional substring filter on operation name."
}
}
})
}
fn schema_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The fully-qualified operation name (e.g. `fs/readFile`)."
}
},
"required": ["name"]
})
}
fn call_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"operation": {
"type": "string",
"description": "The fully-qualified operation name to invoke."
},
"input": {
"type": "object",
"description": "The JSON input object to pass to the operation."
}
},
"required": ["operation"]
})
}
fn batch_input_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"calls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"operation": { "type": "string" },
"input": { "type": "object" }
},
"required": ["operation"]
},
"description": "The operations to invoke in this batch."
}
},
"required": ["calls"]
})
}
pub struct ToMcpGateway {
dispatch: Arc<GatewayDispatch>,
}
impl ToMcpGateway {
pub fn new(dispatch: Arc<GatewayDispatch>) -> Self {
Self { dispatch }
}
pub fn dispatch(&self) -> &Arc<GatewayDispatch> {
&self.dispatch
}
fn extract_identity(context: &RequestContext<RoleServer>) -> Option<Identity> {
Self::extract_identity_from_extensions(&context.extensions)
}
fn extract_identity_from_extensions(extensions: &rmcp::model::Extensions) -> Option<Identity> {
let parts = extensions.get::<http::request::Parts>()?;
parts
.extensions
.get::<Option<Identity>>()
.and_then(Option::clone)
}
async fn handle_search(&self, identity: Option<Identity>) -> CallToolResult {
let response = self
.dispatch
.invoke(identity.clone(), OP_SERVICES_LIST, Value::Null)
.await;
map_search_response(response, identity.as_ref())
}
async fn handle_schema(
&self,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
let name = match arguments
.and_then(|mut a| a.remove("name"))
.and_then(|v| v.as_str().map(str::to_string))
{
Some(n) => n,
None => {
return CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: name"
}));
}
};
let response = self
.dispatch
.invoke(
identity,
OP_SERVICES_SCHEMA,
serde_json::json!({ "name": name }),
)
.await;
envelope_to_call_tool_result(response)
}
async fn handle_call(
&self,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
let (operation, input) = match parse_call_arguments(arguments) {
Ok(pair) => pair,
Err(err) => return err,
};
let response = self.dispatch.invoke(identity, &operation, input).await;
envelope_to_call_tool_result(response)
}
async fn handle_batch(
&self,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
let calls = match arguments
.and_then(|mut a| a.remove("calls"))
.and_then(|v| v.as_array().cloned())
{
Some(arr) => arr,
None => {
return CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: calls"
}));
}
};
let mut results: Vec<Value> = Vec::with_capacity(calls.len());
for call in calls {
let (operation, input) = match parse_call_arguments(call.as_object().cloned()) {
Ok(pair) => pair,
Err(err) => {
results.push(batch_error_value(err));
continue;
}
};
let response = self
.dispatch
.invoke(identity.clone(), &operation, input)
.await;
results.push(envelope_to_value(response));
}
CallToolResult::structured(Value::Array(results))
}
}
fn parse_call_arguments(arguments: Option<JsonObject>) -> Result<(String, Value), CallToolResult> {
let mut map = match arguments {
Some(m) => m,
None => {
return Err(CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: operation"
})));
}
};
let operation = match map
.remove("operation")
.and_then(|v| v.as_str().map(str::to_string))
{
Some(s) => s,
None => {
return Err(CallToolResult::structured_error(serde_json::json!({
"code": "INVALID_INPUT",
"message": "missing required field: operation"
})));
}
};
let input = map.remove("input").unwrap_or(Value::Object(Map::new()));
Ok((operation, input))
}
fn batch_error_value(result: CallToolResult) -> Value {
serde_json::json!({
"isError": result.is_error.unwrap_or(false),
"structuredContent": result.structured_content,
"content": result.content,
})
}
fn map_search_response(response: ResponseEnvelope, identity: Option<&Identity>) -> CallToolResult {
match response.result {
Ok(value) => {
let operations = value
.get("operations")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let filtered: Vec<Value> = operations
.into_iter()
.filter(|op| {
let op_type = op.get("op_type").and_then(Value::as_str).unwrap_or("");
!matches!(op_type, "subscription" | "Subscription")
})
.map(|op| op_to_search_listing(&op, identity))
.collect();
CallToolResult::structured(serde_json::json!({ "operations": filtered }))
}
Err(err) => call_error_to_structured_error(err),
}
}
fn op_to_search_listing(op: &Value, identity: Option<&Identity>) -> Value {
let name = op.get("name").and_then(Value::as_str).unwrap_or("");
let op_type = op.get("op_type").and_then(Value::as_str).unwrap_or("query");
let namespace = op.get("namespace").and_then(Value::as_str).unwrap_or("");
let description = format!("{op_type} operation `{name}` in namespace `{namespace}`");
let _ = identity;
serde_json::json!({
"name": name,
"description": description,
})
}
fn envelope_to_call_tool_result(response: ResponseEnvelope) -> CallToolResult {
match response.result {
Ok(value) => CallToolResult::structured(value),
Err(err) => call_error_to_structured_error(err),
}
}
fn call_error_to_structured_error(err: CallError) -> CallToolResult {
let details = serde_json::to_value(&err).unwrap_or(Value::Null);
CallToolResult::structured_error(details)
}
fn envelope_to_value(response: ResponseEnvelope) -> Value {
match response.result {
Ok(output) => serde_json::json!({
"isError": false,
"output": output,
}),
Err(err) => {
let details = serde_json::to_value(&err).unwrap_or(Value::Null);
serde_json::json!({
"isError": true,
"error": details,
})
}
}
}
fn gateway_tools() -> Vec<Tool> {
vec![
Tool::new(
Cow::Borrowed(TOOL_SEARCH),
Cow::Borrowed(
"List available operations (filtered by the caller's AccessControl). Returns names + descriptions, not full schemas. Subscription operations are excluded.",
),
value_to_object(search_input_schema()),
),
Tool::new(
Cow::Borrowed(TOOL_SCHEMA),
Cow::Borrowed(
"Get the full OperationSpec for an operation (input/output JSON Schemas, error schemas).",
),
value_to_object(schema_input_schema()),
),
Tool::new(
Cow::Borrowed(TOOL_CALL),
Cow::Borrowed(
"Invoke an operation by name with a JSON input. Returns the output as structuredContent, or isError with typed error details for a CallError.",
),
value_to_object(call_input_schema()),
),
Tool::new(
Cow::Borrowed(TOOL_BATCH),
Cow::Borrowed(
"Invoke multiple operations in one tool call. Returns an array of results, each shaped like a `call` result.",
),
value_to_object(batch_input_schema()),
),
]
}
fn value_to_object(value: Value) -> Arc<JsonObject> {
match value {
Value::Object(map) => Arc::new(map),
_ => Arc::new(Map::new()),
}
}
impl rmcp::handler::server::ServerHandler for ToMcpGateway {
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl futures::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_ {
let tools = gateway_tools();
std::future::ready(Ok(ListToolsResult::with_all_items(tools)))
}
fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> impl futures::Future<Output = Result<CallToolResult, rmcp::ErrorData>> + Send + '_ {
let identity = Self::extract_identity(&context);
let name = request.name.to_string();
let arguments = request.arguments;
let this = self;
async move {
let result = match name.as_str() {
TOOL_SEARCH => this.handle_search(identity).await,
TOOL_SCHEMA => this.handle_schema(arguments, identity).await,
TOOL_CALL => this.handle_call(arguments, identity).await,
TOOL_BATCH => this.handle_batch(arguments, identity).await,
unknown => {
let err = CallError::new(
"NOT_FOUND",
format!("unknown gateway tool: {unknown}"),
false,
);
call_error_to_structured_error(err)
}
};
Ok(result)
}
}
fn get_info(&self) -> ServerInfo {
let capabilities = ServerCapabilities::builder().enable_tools().build();
ServerInfo::new(capabilities)
.with_server_info(Implementation::new(
"alknet-to-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"alknet MCP gateway. Call `search` to discover operations, `schema` for an operation's full spec, `call` to invoke, `batch` to invoke many.",
)
}
}
pub type ToMcpService = StreamableHttpService<ToMcpGateway, LocalSessionManager>;
pub fn to_mcp_service(dispatch: Arc<GatewayDispatch>) -> ToMcpService {
let gateway = ToMcpGateway::new(dispatch);
StreamableHttpService::new(
move || Ok(ToMcpGateway::new(Arc::clone(gateway.dispatch()))),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use alknet_call::protocol::wire::ResponseEnvelope;
use alknet_call::registry::context::ScopedPeerEnv;
use alknet_call::registry::discovery::{
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
};
use alknet_call::registry::registration::{
make_handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry,
};
use alknet_call::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
use alknet_core::types::Capabilities;
use rmcp::model::Extensions;
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
}
impl StaticIdentityProvider {
fn new() -> Self {
Self {
tokens: StdMutex::new(HashMap::new()),
}
}
fn with_token(self, token: &str, identity: Identity) -> Self {
self.tokens
.lock()
.unwrap()
.insert(token.to_string(), identity);
self
}
}
impl IdentityProvider for StaticIdentityProvider {
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
let token_str = String::from_utf8_lossy(&token.raw);
self.tokens.lock().unwrap().get(token_str.as_ref()).cloned()
}
}
fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity {
Identity {
id: id.to_string(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
resources: HashMap::new(),
}
}
fn external_spec(name: &str, op_type: OperationType, acl: AccessControl) -> OperationSpec {
OperationSpec::new(
name,
op_type,
Visibility::External,
serde_json::json!({}),
serde_json::json!({}),
vec![],
acl,
)
}
fn make_echo_handler() -> alknet_call::registry::registration::Handler {
make_handler(
|input, context| async move { ResponseEnvelope::ok(context.request_id, input) },
)
}
fn full_registry_with_ops(
specs: Vec<(String, OperationType, AccessControl)>,
) -> Arc<OperationRegistry> {
let mut inner = OperationRegistry::new();
for (name, op_type, acl) in specs {
inner
.register(HandlerRegistration::new(
external_spec(&name, op_type, acl),
HandlerKind::Once(make_echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
}
let inner = Arc::new(inner);
let mut dispatch_registry = OperationRegistry::new();
for op in inner.list_operations() {
dispatch_registry
.register(HandlerRegistration::new(
external_spec(&op.name, op.op_type, op.access_control.clone()),
HandlerKind::Once(make_echo_handler()),
OperationProvenance::Local,
None,
None,
Capabilities::new(),
))
.unwrap();
}
dispatch_registry
.register(HandlerRegistration::new(
services_list_spec(),
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
dispatch_registry
.register(HandlerRegistration::new(
services_schema_spec(),
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
OperationProvenance::Local,
None,
ScopedPeerEnv::empty().into(),
Capabilities::new(),
))
.unwrap();
Arc::new(dispatch_registry)
}
fn dispatch(
registry: Arc<OperationRegistry>,
provider: Arc<dyn IdentityProvider>,
) -> Arc<GatewayDispatch> {
Arc::new(GatewayDispatch::new(registry, provider))
}
fn provider() -> Arc<dyn IdentityProvider> {
Arc::new(StaticIdentityProvider::new())
}
fn extensions_with_identity(identity: Option<Identity>) -> Extensions {
let request = http::Request::builder()
.method(http::Method::POST)
.uri("/mcp")
.body(())
.expect("valid request");
let (mut parts, _) = request.into_parts();
parts.extensions.insert(identity);
let mut extensions = Extensions::new();
extensions.insert(parts);
extensions
}
async fn invoke_tool(
gateway: &ToMcpGateway,
name: &str,
arguments: Option<JsonObject>,
identity: Option<Identity>,
) -> CallToolResult {
match name {
TOOL_SEARCH => gateway.handle_search(identity).await,
TOOL_SCHEMA => gateway.handle_schema(arguments, identity).await,
TOOL_CALL => gateway.handle_call(arguments, identity).await,
TOOL_BATCH => gateway.handle_batch(arguments, identity).await,
unknown => {
let err = CallError::new(
"NOT_FOUND",
format!("unknown gateway tool: {unknown}"),
false,
);
call_error_to_structured_error(err)
}
}
}
#[tokio::test]
async fn list_tools_returns_exactly_four_gateway_tools() {
let _gateway = ToMcpGateway::new(dispatch(full_registry_with_ops(vec![]), provider()));
let tools = gateway_tools();
let names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
assert_eq!(names.len(), 4);
assert!(names.contains(&"search".to_string()));
assert!(names.contains(&"schema".to_string()));
assert!(names.contains(&"call".to_string()));
assert!(names.contains(&"batch".to_string()));
}
#[tokio::test]
async fn list_tools_does_not_leak_registry_operations() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let _gateway = ToMcpGateway::new(dispatch(registry, provider()));
let tools = gateway_tools();
for tool in &tools {
assert_ne!(tool.name, "fs/readFile");
assert_ne!(tool.name, "services/list");
assert_ne!(tool.name, "services/schema");
}
assert_eq!(tools.len(), 4);
}
#[tokio::test]
async fn search_returns_access_control_filtered_ops_excluding_subscriptions() {
let registry = full_registry_with_ops(vec![
(
"public/echo".to_string(),
OperationType::Query,
AccessControl::default(),
),
(
"admin/secret".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
),
(
"events/stream".to_string(),
OperationType::Subscription,
AccessControl::default(),
),
]);
let idp: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let gateway = ToMcpGateway::new(dispatch(registry, idp));
let result = invoke_tool(
&gateway,
"search",
None,
Some(identity_with_scopes("user", &["user"])),
)
.await;
assert_eq!(result.is_error, Some(false));
let structured = result.structured_content.expect("structured present");
let ops = structured
.get("operations")
.and_then(Value::as_array)
.expect("operations array");
let names: Vec<&str> = ops
.iter()
.filter_map(|o| o.get("name").and_then(Value::as_str))
.collect();
assert!(names.contains(&"public/echo"));
assert!(
!names.contains(&"admin/secret"),
"ACL-filtered op must not appear"
);
assert!(
!names.contains(&"events/stream"),
"Subscription op must be excluded"
);
for op in ops {
assert!(
op.get("description").is_some(),
"each entry has a description"
);
assert!(
op.get("input_schema").is_none(),
"search must not return full schemas"
);
}
}
#[tokio::test]
async fn schema_returns_full_operation_spec() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert("name".to_string(), Value::String("fs/readFile".to_string()));
let result = invoke_tool(&gateway, "schema", Some(args), None).await;
assert_eq!(result.is_error, Some(false));
let structured = result.structured_content.expect("structured present");
assert_eq!(
structured.get("name"),
Some(&Value::String("fs/readFile".to_string()))
);
assert!(structured.get("input_schema").is_some());
assert!(structured.get("output_schema").is_some());
assert!(structured.get("error_schemas").is_some());
assert!(structured.get("access_control").is_some());
}
#[tokio::test]
async fn call_returns_structured_for_success() {
let registry = full_registry_with_ops(vec![(
"echo/run".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("echo/run".to_string()),
);
args.insert("input".to_string(), serde_json::json!({ "msg": "hi" }));
let result = invoke_tool(&gateway, "call", Some(args), None).await;
assert_eq!(result.is_error, Some(false));
assert_eq!(
result.structured_content,
Some(serde_json::json!({ "msg": "hi" }))
);
}
#[tokio::test]
async fn call_returns_structured_error_for_call_error() {
let registry = full_registry_with_ops(vec![]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("no/such".to_string()),
);
args.insert("input".to_string(), Value::Object(Map::new()));
let result = invoke_tool(&gateway, "call", Some(args), None).await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("NOT_FOUND".to_string()))
);
}
#[tokio::test]
async fn batch_returns_array_of_results() {
let registry = full_registry_with_ops(vec![(
"echo/run".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let mut args = Map::new();
args.insert(
"calls".to_string(),
serde_json::json!([
{ "operation": "echo/run", "input": { "n": 1 } },
{ "operation": "no/such", "input": {} },
]),
);
let result = invoke_tool(&gateway, "batch", Some(args), None).await;
assert_eq!(result.is_error, Some(false));
let structured = result.structured_content.expect("structured present");
let arr = structured.as_array().expect("batch returns array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].get("isError"), Some(&Value::Bool(false)));
assert_eq!(arr[1].get("isError"), Some(&Value::Bool(true)));
}
#[tokio::test]
async fn call_with_restricted_op_and_unauthorized_identity_returns_forbidden_error() {
let registry = full_registry_with_ops(vec![(
"admin/run".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
)]);
let idp: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let gateway = ToMcpGateway::new(dispatch(registry, idp));
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("admin/run".to_string()),
);
args.insert("input".to_string(), Value::Object(Map::new()));
let result = invoke_tool(&gateway, "call", Some(args), None).await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("FORBIDDEN".to_string()))
);
}
#[tokio::test]
async fn unknown_tool_name_returns_not_found_structured_error() {
let gateway = ToMcpGateway::new(dispatch(Arc::new(OperationRegistry::new()), provider()));
let result = invoke_tool(&gateway, "bogus", None, None).await;
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error present");
assert_eq!(
structured.get("code"),
Some(&Value::String("NOT_FOUND".to_string()))
);
}
#[tokio::test]
async fn identity_survives_rmcp_framing_into_call_tool() {
let registry = full_registry_with_ops(vec![(
"admin/run".to_string(),
OperationType::Query,
AccessControl {
required_scopes: vec!["admin".to_string()],
..Default::default()
},
)]);
let idp: Arc<dyn IdentityProvider> = Arc::new(
StaticIdentityProvider::new()
.with_token("alk_admin", identity_with_scopes("admin-peer", &["admin"])),
);
let gateway = ToMcpGateway::new(dispatch(registry, idp));
let admin_identity = identity_with_scopes("admin-peer", &["admin"]);
let extensions = extensions_with_identity(Some(admin_identity.clone()));
let extracted = ToMcpGateway::extract_identity_from_extensions(&extensions);
assert_eq!(
extracted.as_ref().map(|i| &i.id),
Some(&"admin-peer".to_string())
);
let mut args = Map::new();
args.insert(
"operation".to_string(),
Value::String("admin/run".to_string()),
);
args.insert("input".to_string(), serde_json::json!({ "ok": 1 }));
let result = gateway.handle_call(Some(args), extracted).await;
assert_eq!(result.is_error, Some(false));
assert_eq!(
result.structured_content,
Some(serde_json::json!({ "ok": 1 }))
);
}
#[test]
fn extract_identity_returns_none_when_no_parts_in_extensions() {
let extensions = Extensions::new();
assert!(ToMcpGateway::extract_identity_from_extensions(&extensions).is_none());
}
#[test]
fn extract_identity_returns_none_when_parts_have_no_identity() {
let extensions = extensions_with_identity(None);
assert!(ToMcpGateway::extract_identity_from_extensions(&extensions).is_none());
}
#[test]
fn extract_identity_reads_stashed_option_identity_from_parts() {
let id = identity_with_scopes("caller", &["read"]);
let extensions = extensions_with_identity(Some(id.clone()));
let extracted = ToMcpGateway::extract_identity_from_extensions(&extensions);
assert_eq!(
extracted.as_ref().map(|i| i.id.clone()),
Some("caller".to_string())
);
assert_eq!(
extracted.as_ref().map(|i| i.scopes.clone()),
Some(vec!["read".to_string()])
);
}
#[test]
fn to_mcp_is_not_an_operation_adapter() {
fn assert_not_adapter<T>() {}
assert_not_adapter::<ToMcpGateway>();
}
#[test]
fn gateway_tools_definition_is_stable() {
let tools = gateway_tools();
assert_eq!(tools.len(), 4);
assert_eq!(tools[0].name, "search");
assert_eq!(tools[1].name, "schema");
assert_eq!(tools[2].name, "call");
assert_eq!(tools[3].name, "batch");
}
#[tokio::test]
async fn search_schema_call_round_trip() {
let registry = full_registry_with_ops(vec![(
"fs/readFile".to_string(),
OperationType::Query,
AccessControl::default(),
)]);
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
let search_result = invoke_tool(&gateway, "search", None, None).await;
let ops = search_result
.structured_content
.as_ref()
.and_then(|v| v.get("operations"))
.and_then(Value::as_array)
.expect("search ops");
let first_name = ops[0].get("name").and_then(Value::as_str).expect("name");
assert_eq!(first_name, "fs/readFile");
let mut schema_args = Map::new();
schema_args.insert("name".to_string(), Value::String(first_name.to_string()));
let schema_result = invoke_tool(&gateway, "schema", Some(schema_args), None).await;
assert_eq!(
schema_result
.structured_content
.as_ref()
.and_then(|v| v.get("name"))
.and_then(Value::as_str),
Some("fs/readFile")
);
let mut call_args = Map::new();
call_args.insert(
"operation".to_string(),
Value::String(first_name.to_string()),
);
call_args.insert(
"input".to_string(),
serde_json::json!({ "path": "/etc/hosts" }),
);
let call_result = invoke_tool(&gateway, "call", Some(call_args), None).await;
assert_eq!(
call_result.structured_content,
Some(serde_json::json!({ "path": "/etc/hosts" }))
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,329 @@
//! Shared HTTP client: `reqwest_middleware::ClientWithMiddleware` with a
//! retry stack (RetryTransientMiddleware + inlined RetryAfterMiddleware),
//! connection pooling, keep-alive, TLS, and rebuild-and-swap hot-reload.
//!
//! Credential injection happens per-request (from
//! `OperationContext.capabilities`), not at client construction — the
//! client is shared across all operations, the credentials are per-call.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use reqwest::ClientBuilder;
use reqwest_middleware::ClientWithMiddleware;
use reqwest_retry::policies::ExponentialBackoff;
use reqwest_retry::RetryTransientMiddleware;
use thiserror::Error;
use super::retry_after::RetryAfterMiddleware;
const DEFAULT_RETRY_AFTER_CAPACITY: usize = 256;
#[derive(Debug, Clone)]
pub struct ClientCertConfig {
pub cert_pem: PathBuf,
pub key_pem: PathBuf,
}
#[derive(Debug, Clone)]
pub struct HttpClientConfig {
pub pool_max_idle_per_host: Option<usize>,
pub request_timeout: Option<Duration>,
pub retry_policy: ExponentialBackoff,
pub ca_bundle: Option<PathBuf>,
pub client_cert: Option<ClientCertConfig>,
}
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
pool_max_idle_per_host: None,
request_timeout: None,
retry_policy: ExponentialBackoff::builder().build_with_max_retries(3),
ca_bundle: None,
client_cert: None,
}
}
}
#[derive(Debug, Error)]
pub enum HttpClientBuildError {
#[error("failed to read CA bundle from {path}: {source}")]
CaBundleRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse CA bundle at {path}: {source}")]
CaBundleParse {
path: PathBuf,
#[source]
source: reqwest::Error,
},
#[error("failed to read client cert from {path}: {source}")]
ClientCertRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse client cert at {path}: {source}")]
ClientCertParse {
path: PathBuf,
#[source]
source: reqwest::Error,
},
#[error("failed to build reqwest client: {0}")]
Build(reqwest::Error),
}
pub struct SharedHttpClient {
inner: ArcSwap<ClientWithMiddleware>,
config: ArcSwap<HttpClientConfig>,
}
impl std::fmt::Debug for SharedHttpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SharedHttpClient")
.field("config", &self.config.load())
.finish_non_exhaustive()
}
}
impl SharedHttpClient {
pub fn new(config: HttpClientConfig) -> Result<Self, HttpClientBuildError> {
let client = build_client(&config)?;
Ok(Self {
inner: ArcSwap::from_pointee(client),
config: ArcSwap::from_pointee(config),
})
}
pub fn client(&self) -> Arc<ClientWithMiddleware> {
self.inner.load_full()
}
pub fn config(&self) -> Arc<HttpClientConfig> {
self.config.load_full()
}
pub fn reload(&self, config: HttpClientConfig) -> Result<(), HttpClientBuildError> {
let client = build_client(&config)?;
self.config.store(Arc::new(config));
self.inner.store(Arc::new(client));
Ok(())
}
}
fn build_client(config: &HttpClientConfig) -> Result<ClientWithMiddleware, HttpClientBuildError> {
let mut builder = ClientBuilder::new();
if let Some(pool_max_idle) = config.pool_max_idle_per_host {
builder = builder.pool_max_idle_per_host(pool_max_idle);
}
if let Some(timeout) = config.request_timeout {
builder = builder.timeout(timeout);
}
if let Some(ca_bundle_path) = &config.ca_bundle {
let pem =
std::fs::read(ca_bundle_path).map_err(|source| HttpClientBuildError::CaBundleRead {
path: ca_bundle_path.clone(),
source,
})?;
let certs = reqwest::Certificate::from_pem_bundle(&pem).map_err(|source| {
HttpClientBuildError::CaBundleParse {
path: ca_bundle_path.clone(),
source,
}
})?;
for cert in certs {
builder = builder.add_root_certificate(cert);
}
}
if let Some(client_cert_cfg) = &config.client_cert {
let cert_pem = std::fs::read(&client_cert_cfg.cert_pem).map_err(|source| {
HttpClientBuildError::ClientCertRead {
path: client_cert_cfg.cert_pem.clone(),
source,
}
})?;
let key_pem = std::fs::read(&client_cert_cfg.key_pem).map_err(|source| {
HttpClientBuildError::ClientCertRead {
path: client_cert_cfg.key_pem.clone(),
source,
}
})?;
let identity = reqwest::Identity::from_pem(concat_pem(&cert_pem, &key_pem).as_slice())
.map_err(|source| HttpClientBuildError::ClientCertParse {
path: client_cert_cfg.cert_pem.clone(),
source,
})?;
builder = builder.identity(identity);
}
let reqwest_client = builder.build().map_err(HttpClientBuildError::Build)?;
let client = reqwest_middleware::ClientBuilder::new(reqwest_client)
.with(RetryTransientMiddleware::new_with_policy(
config.retry_policy,
))
.with(RetryAfterMiddleware::with_capacity(
DEFAULT_RETRY_AFTER_CAPACITY,
))
.build();
Ok(client)
}
fn concat_pem(cert: &[u8], key: &[u8]) -> Vec<u8> {
let mut combined = Vec::with_capacity(cert.len() + key.len() + 1);
combined.extend_from_slice(cert);
if !cert.is_empty() && cert.last() != Some(&b'\n') {
combined.push(b'\n');
}
combined.extend_from_slice(key);
combined
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::SystemTime;
fn minimal_config() -> HttpClientConfig {
HttpClientConfig {
pool_max_idle_per_host: Some(8),
request_timeout: Some(Duration::from_secs(30)),
retry_policy: ExponentialBackoff::builder().build_with_max_retries(2),
ca_bundle: None,
client_cert: None,
}
}
#[test]
fn client_returns_a_usable_client_with_middleware() {
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
let client = http.client();
let request = client
.get("https://api.example.com/v1/chat")
.build()
.expect("RequestBuilder builds");
assert_eq!(request.method(), reqwest::Method::GET);
assert_eq!(request.url().as_str(), "https://api.example.com/v1/chat");
}
#[test]
fn reload_swaps_the_client_returned_by_client() {
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
let before = http.client();
let new_config = HttpClientConfig {
pool_max_idle_per_host: Some(32),
request_timeout: Some(Duration::from_secs(10)),
retry_policy: ExponentialBackoff::builder().build_with_max_retries(5),
ca_bundle: None,
client_cert: None,
};
http.reload(new_config.clone()).expect("reload succeeds");
let after = http.client();
assert!(
!Arc::ptr_eq(&before, &after),
"reload must swap in a new ClientWithMiddleware"
);
let config = http.config();
assert_eq!(config.pool_max_idle_per_host, Some(32));
assert_eq!(config.request_timeout, Some(Duration::from_secs(10)));
}
#[test]
fn config_returns_current_config() {
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
let config = http.config();
assert_eq!(config.pool_max_idle_per_host, Some(8));
assert_eq!(config.request_timeout, Some(Duration::from_secs(30)));
}
#[test]
fn default_config_has_sensible_defaults() {
let config = HttpClientConfig::default();
assert!(config.pool_max_idle_per_host.is_none());
assert!(config.request_timeout.is_none());
assert!(config.ca_bundle.is_none());
assert!(config.client_cert.is_none());
assert_eq!(config.retry_policy.max_n_retries, Some(3));
}
#[test]
fn reload_with_ca_bundle_missing_file_errors() {
let http = SharedHttpClient::new(minimal_config()).expect("client builds");
let bad_config = HttpClientConfig {
ca_bundle: Some(PathBuf::from("/nonexistent/ca-bundle.pem")),
..minimal_config()
};
let err = http.reload(bad_config).unwrap_err();
assert!(matches!(err, HttpClientBuildError::CaBundleRead { .. }));
}
#[test]
fn concat_pem_inserts_separator_between_cert_and_key() {
let cert = b"-----BEGIN CERTIFICATE-----\ncert-body\n-----END CERTIFICATE-----";
let key = b"-----BEGIN PRIVATE KEY-----\nkey-body\n-----END PRIVATE KEY-----";
let combined = concat_pem(cert, key);
assert!(combined.starts_with(b"-----BEGIN CERTIFICATE-----"));
assert!(combined.windows(20).any(|w| w == b"-----END CERTIFICATE"));
assert!(combined.windows(18).any(|w| w == b"-----BEGIN PRIVATE"));
}
#[test]
fn concat_pem_handles_cert_already_terminated_with_newline() {
let cert = b"-----BEGIN CERTIFICATE-----\ncert-body\n-----END CERTIFICATE-----\n";
let key = b"-----BEGIN PRIVATE KEY-----\nkey-body\n-----END PRIVATE KEY-----";
let combined = concat_pem(cert, key);
let joined = std::str::from_utf8(&combined).unwrap();
assert!(
!joined.contains("-----END CERTIFICATE----------BEGIN PRIVATE"),
"must not concatenate without a separator when cert lacks trailing newline"
);
assert!(joined.contains("-----END CERTIFICATE-----\n-----BEGIN PRIVATE"));
}
#[test]
fn client_cert_config_constructs() {
let cfg = ClientCertConfig {
cert_pem: PathBuf::from("/etc/cert.pem"),
key_pem: PathBuf::from("/etc/key.pem"),
};
assert_eq!(cfg.cert_pem, PathBuf::from("/etc/cert.pem"));
assert_eq!(cfg.key_pem, PathBuf::from("/etc/key.pem"));
}
#[test]
fn new_with_missing_ca_bundle_errors() {
let config = HttpClientConfig {
ca_bundle: Some(PathBuf::from("/nonexistent/ca-bundle.pem")),
..HttpClientConfig::default()
};
let err = SharedHttpClient::new(config).unwrap_err();
assert!(matches!(err, HttpClientBuildError::CaBundleRead { .. }));
}
#[test]
fn build_error_display_contains_path() {
let err = HttpClientBuildError::CaBundleRead {
path: PathBuf::from("/nonexistent/ca.pem"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
};
let rendered = format!("{err}");
assert!(rendered.contains("/nonexistent/ca.pem"));
}
#[test]
fn retry_after_capacity_constant_is_bounded() {
let cap = DEFAULT_RETRY_AFTER_CAPACITY;
assert!(cap > 0, "RetryAfterMiddleware storage must be non-zero");
assert!(cap <= 4096, "RetryAfterMiddleware storage must be bounded");
}
#[test]
fn no_env_vars_read_in_default_config() {
let _ = SystemTime::now();
let config = HttpClientConfig::default();
assert!(config.ca_bundle.is_none());
}
}

Some files were not shown because too many files have changed in this diff Show More