57 Commits

Author SHA1 Message Date
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
355 changed files with 19816 additions and 52365 deletions

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. 1. **No comments in code** — Per project convention.
2. **Error handling** — Use `anyhow::Result` for application code, `thiserror` for 2. **Error handling** — Use `anyhow::Result` for application code, `thiserror` for
library error types. Never panic in library code. 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. crate should compile lean.
4. **Async runtime** — `tokio` is the async runtime. All I/O is async. 7. **Async runtime** — `tokio` is the async runtime. All I/O is async.
5. **Naming conventions** — Rust standard: `snake_case` for functions/variables/ 8. **Naming conventions** — Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants. 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. `mod.rs` or `lib.rs` as appropriate.
## Key Principles ## Key Principles

2822
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

242
README.md
View File

@@ -1,233 +1,37 @@
# Alknet # 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 **A service IS an ALPN.** One endpoint, one port, many protocols — dispatched by the TLS handshake, not by application-level peeking or separate listeners.
- **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)).
## Crates ## Crates
| Crate | Description | | Crate | Status | Description |
|-------|-------------| |-------|--------|-------------|
| `alknet-core` | Core library: transport trait, SOCKS5 server, port forwarding, auth, server handler | | `alknet-vault` | stable | Local key vault: BIP39/SLIP-0010/AES-GCM key derivation and encryption |
| `alknet` | CLI binary (`alknet connect` / `alknet serve`) | | `alknet-core` | planned | ProtocolHandler trait, ALPN router, auth/identity, config |
| `alknet-napi` | Node.js native addon via napi-rs (`connect()` / `serve()`) | | `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 | - [ALPN-as-service architecture](docs/research/pivot/alpn-service-architecture.md) — pivot proposal
|---------|-------|---------|-------------| - [Cleanup plan](docs/research/pivot/cleanup-plan.md) — greenfield transition plan
| `tls` | `alknet-core`, `alknet` | yes | TLS transport (tokio-rustls) | - [SDD process](docs/sdd_process.md) — spec-driven development process
| `iroh` | `alknet-core`, `alknet` | yes | iroh QUIC P2P transport | - [Research references](docs/research/references/) — iroh, russh, russh-sftp deep dives
| `acme` | `alknet-core` | no | ACME/Let's Encrypt auto-cert provisioning |
| `testutil` | `alknet-core` | no | Test utilities (for internal use) |
## Transport modes Reference implementation (previous architecture) is preserved at `/workspace/@alkdev/alknet-main/`.
| 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.
## License ## License

View File

@@ -3,54 +3,30 @@ name = "alknet-core"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.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 repository.workspace = true
[lib] [lib]
name = "alknet_core" name = "alknet_core"
[features] [features]
default = [] default = ["quinn"]
tls = ["dep:tokio-rustls", "dep:rustls", "dep:rustls-pki-types", "dep:webpki-roots"] quinn = ["dep:quinn"]
iroh = ["dep:iroh", "dep:url"] iroh = ["dep:iroh"]
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 = []
[dependencies] [dependencies]
russh = "0.49"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tracing = "0.1" quinn = { version = "0.11", optional = true }
anyhow = "1" iroh = { version = "0.35", optional = true }
thiserror = "2" rustls = "0.23"
tokio-util = { version = "0.7", features = ["compat"] } rustls-pki-types = "1"
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"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10" toml = "0.8"
hex = "0.4" arc-swap = "1"
axum = { version = "0.8", optional = true } async-trait = "0.1"
hyper = { version = "1", optional = true } tracing = "0.1"
hyper-util = { version = "0.1", features = ["tokio", "server", "service"], optional = true } thiserror = "2"
tower = { version = "0.5", optional = true } zeroize = { version = "1", features = ["derive"] }
http-body-util = { version = "0.1", optional = true } bytes = "1"
futures = "0.3"
[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,6 @@
//! Authentication: `AuthContext`, `Identity`, `IdentityProvider`, `AuthToken`,
//! `ConfigIdentityProvider`.
//!
//! See `docs/architecture/crates/core/auth.md` for the full specification.
// TODO: implement

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,6 @@
//! Configuration: `StaticConfig`, `DynamicConfig`, `AuthPolicy`, `ApiKeyEntry`,
//! `RateLimitConfig`, `ConfigReloadHandle`, `ConfigError`, `TlsIdentity`.
//!
//! See `docs/architecture/crates/core/config.md` for the full specification.
// TODO: implement

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);
}
}

View File

@@ -0,0 +1,5 @@
//! Endpoint: `AlknetEndpoint`, `HandlerRegistry`, `EndpointError`.
//!
//! See `docs/architecture/crates/core/endpoint.md` for the full specification.
// TODO: implement

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

@@ -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,12 @@
//! # 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 //! Every handler crate depends on this crate. It provides the
//! tunnel tool. This crate provides the transport abstraction, SOCKS5 server, port forwarding, //! [`ProtocolHandler`][crate::types::ProtocolHandler] trait, the
//! authentication, and server handler — everything needed to build an alknet client or server //! [`Connection`][crate::types::Connection] wrapper, auth primitives,
//! on top of pluggable transports. //! hot-reloadable configuration, and the [`AlknetEndpoint`][crate::endpoint::AlknetEndpoint]
//! //! that dispatches incoming QUIC connections by ALPN string.
//! > **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(())
//! }
//! ```
pub mod auth; pub mod auth;
pub mod call;
pub mod client;
pub mod config; pub mod config;
pub mod credentials; pub mod endpoint;
pub mod error; pub mod types;
pub mod interface;
pub mod server;
pub mod socks5;
pub mod transport;
#[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};

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

@@ -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,6 @@
//! Core types: `ProtocolHandler`, `HandlerError`, `Connection`, `BiStream`,
//! `SendStream`, `RecvStream`, `StreamError`, `Capabilities`.
//!
//! See `docs/architecture/crates/core/core-types.md` for the full specification.
// TODO: implement

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

@@ -1,25 +0,0 @@
[package]
name = "alknet-napi"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Node.js native addon for Alknet via napi-rs: connect() and serve() SSH tunnel functions"
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
alknet-core = { path = "../alknet-core", features = ["tls", "iroh"] }
napi = { version = "3", features = ["async", "error_anyhow"] }
napi-derive = "3"
tokio = { version = "1", features = ["io-util", "sync", "rt", "macros", "net", "time", "signal"] }
russh = "0.49"
async-trait = "0.1"
rustls-pemfile = "2"
rustls-pki-types = "1"
iroh = "0.34"
ipnetwork = "0.21"
url = "2"
arc-swap = "1"
tracing = "0.1"

View File

@@ -1,304 +0,0 @@
//! NAPI `connect()` function and `AlknetStream` type.
//!
//! Opens a single SSH channel as a duplex stream for programmatic use.
//! Unlike the CLI client, this does not start a SOCKS5 server or port forwards —
//! it provides a raw stream that JavaScript code can read from and write to.
use std::net::SocketAddr;
use std::sync::Arc;
use napi::bindgen_prelude::*;
use napi_derive::napi;
use russh::client;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
use alknet_core::auth::client_auth::{ClientAuthConfig, ClientHandler};
use alknet_core::auth::keys::KeySource;
use alknet_core::transport::{IrohTransport, TcpTransport, TlsTransport, Transport};
const DEFAULT_HOST: &str = "alknet-control";
const DEFAULT_PORT: u32 = 0;
#[napi(object)]
pub struct AlknetConnectOptions {
pub server: Option<String>,
pub peer: Option<String>,
pub transport: String,
pub identity: Option<Either<String, Buffer>>,
pub tls_server_name: Option<String>,
pub insecure: Option<bool>,
pub iroh_relay: Option<String>,
pub proxy: Option<String>,
}
fn resolve_key_source(identity: &Option<Either<String, Buffer>>) -> Result<KeySource> {
match identity {
None => Err(Error::new(
Status::InvalidArg,
"identity is required: provide a file path (string) or key data (Buffer)",
)),
Some(Either::A(path)) => Ok(KeySource::File(path.into())),
Some(Either::B(buf)) => Ok(KeySource::Memory(buf.to_vec())),
}
}
fn parse_addr(addr_str: &str) -> Result<SocketAddr> {
addr_str.parse().map_err(|e| {
Error::new(
Status::InvalidArg,
format!("invalid server address '{}': {}", addr_str, e),
)
})
}
#[napi]
pub struct AlknetStream {
read: Arc<Mutex<tokio::io::ReadHalf<russh::ChannelStream<client::Msg>>>>,
write: Arc<Mutex<tokio::io::WriteHalf<russh::ChannelStream<client::Msg>>>>,
}
#[napi]
impl AlknetStream {
#[napi]
pub async fn read(&self, size: u32) -> Result<Buffer> {
let mut buf = vec![0u8; size as usize];
let mut guard = self.read.lock().await;
let n = guard
.read(&mut buf)
.await
.map_err(|e| Error::new(Status::GenericFailure, format!("read failed: {}", e)))?;
if n == 0 {
return Ok(Vec::<u8>::new().into());
}
buf.truncate(n);
Ok(buf.into())
}
#[napi]
pub async fn write(&self, data: Buffer) -> Result<()> {
let mut guard = self.write.lock().await;
guard
.write_all(&data)
.await
.map_err(|e| Error::new(Status::GenericFailure, format!("write failed: {}", e)))?;
Ok(())
}
#[napi]
pub async fn close(&self) -> Result<()> {
let mut guard = self.write.lock().await;
guard
.shutdown()
.await
.map_err(|e| Error::new(Status::GenericFailure, format!("close failed: {}", e)))
}
}
#[napi]
pub async fn connect(options: AlknetConnectOptions) -> Result<AlknetStream> {
let key_source = resolve_key_source(&options.identity)?;
let auth_config = Arc::new(
ClientAuthConfig::from_key_source(key_source)
.map_err(|e| Error::new(Status::InvalidArg, format!("invalid identity key: {}", e)))?,
);
let transport_mode = options.transport.to_lowercase();
let handler = ClientHandler::from_config(&auth_config);
let username = "alknet".to_string();
let config = Arc::new(client::Config::default());
let mut handle: client::Handle<ClientHandler> = match transport_mode.as_str() {
"tcp" => {
let server = options.server.as_ref().ok_or_else(|| {
Error::new(Status::InvalidArg, "server is required for tcp transport")
})?;
let addr = parse_addr(server)?;
let transport = TcpTransport::new(addr);
let stream = transport.connect().await.map_err(|e| {
Error::new(Status::GenericFailure, format!("tcp connect failed: {}", e))
})?;
client::connect_stream(config, stream, handler)
.await
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("ssh handshake failed: {}", e),
)
})?
}
"tls" => {
let server = options.server.as_ref().ok_or_else(|| {
Error::new(Status::InvalidArg, "server is required for tls transport")
})?;
let addr = parse_addr(server)?;
let mut transport = TlsTransport::new(addr);
if let Some(ref name) = options.tls_server_name {
transport = transport.with_server_name(name);
}
if let Some(true) = options.insecure {
transport = transport.with_insecure(true);
}
let stream = transport.connect().await.map_err(|e| {
Error::new(Status::GenericFailure, format!("tls connect failed: {}", e))
})?;
client::connect_stream(config, stream, handler)
.await
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("ssh handshake failed: {}", e),
)
})?
}
"iroh" => {
let peer = options.peer.as_ref().ok_or_else(|| {
Error::new(Status::InvalidArg, "peer is required for iroh transport")
})?;
let node_id: iroh::NodeId = peer.parse().map_err(|e| {
Error::new(
Status::InvalidArg,
format!("invalid iroh peer ID '{}': {}", peer, e),
)
})?;
let relay_url: Option<iroh::RelayUrl> = match options.iroh_relay.as_deref() {
Some(u) => Some(u.parse().map_err(|e| {
Error::new(Status::InvalidArg, format!("invalid iroh relay URL: {}", e))
})?),
None => None,
};
let proxy_url: Option<url::Url> = match options.proxy.as_deref() {
Some(u) => Some(u.parse().map_err(|e| {
Error::new(Status::InvalidArg, format!("invalid proxy URL: {}", e))
})?),
None => None,
};
let transport = IrohTransport::new(node_id, relay_url, proxy_url)
.await
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("iroh endpoint setup failed: {}", e),
)
})?;
let stream = transport.connect().await.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("iroh connect failed: {}", e),
)
})?;
client::connect_stream(config, stream, handler)
.await
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("ssh handshake failed: {}", e),
)
})?
}
_ => {
return Err(Error::new(
Status::InvalidArg,
format!(
"unknown transport '{}'; expected tcp, tls, or iroh",
transport_mode
),
));
}
};
let auth_ok = auth_config
.authenticate(&mut handle, &username)
.await
.map_err(|e| Error::new(Status::GenericFailure, format!("ssh auth failed: {}", e)))?;
if !auth_ok {
return Err(Error::new(
Status::GenericFailure,
"ssh authentication rejected",
));
}
let channel = handle
.channel_open_direct_tcpip(DEFAULT_HOST, DEFAULT_PORT, "127.0.0.1", 0)
.await
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("failed to open ssh channel: {}", e),
)
})?;
let stream = channel.into_stream();
let (read_half, write_half) = tokio::io::split(stream);
Ok(AlknetStream {
read: Arc::new(Mutex::new(read_half)),
write: Arc::new(Mutex::new(write_half)),
})
}
#[cfg(test)]
mod tests {
use super::*;
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 resolve_key_source_file_path() {
let identity = Some(Either::<String, Buffer>::A("/path/to/key".to_string()));
let result = resolve_key_source(&identity);
assert!(result.is_ok());
match result.unwrap() {
KeySource::File(p) => assert_eq!(p.to_str(), Some("/path/to/key")),
_ => panic!("expected File variant"),
}
}
#[test]
fn resolve_key_source_buffer() {
let identity = Some(Either::<String, Buffer>::B(Buffer::from(
ED25519_PRIVATE_KEY.as_bytes().to_vec(),
)));
let result = resolve_key_source(&identity);
assert!(result.is_ok());
match result.unwrap() {
KeySource::Memory(data) => assert!(!data.is_empty()),
_ => panic!("expected Memory variant"),
}
}
#[test]
fn resolve_key_source_missing() {
let identity: Option<Either<String, Buffer>> = None;
let result = resolve_key_source(&identity);
assert!(result.is_err());
}
#[test]
fn parse_addr_valid() {
let addr = parse_addr("127.0.0.1:22");
assert!(addr.is_ok());
assert_eq!(addr.unwrap().port(), 22);
}
#[test]
fn parse_addr_invalid() {
let addr = parse_addr("not-an-address");
assert!(addr.is_err());
}
#[test]
fn auth_config_from_memory_key() {
let source = KeySource::Memory(ED25519_PRIVATE_KEY.as_bytes().to_vec());
let config = ClientAuthConfig::from_key_source(source);
assert!(config.is_ok());
}
#[test]
fn auth_config_from_invalid_key() {
let source = KeySource::Memory(b"not-a-key".to_vec());
let config = ClientAuthConfig::from_key_source(source);
assert!(config.is_err());
}
}

View File

@@ -1,29 +0,0 @@
//! # alknet-napi
//!
//! Node.js native addon for [Alknet](https://git.alk.dev/alkdev/alknet) via napi-rs.
//! Exposes `connect()` and `serve()` functions for programmatic SSH tunnel creation.
//!
//! > **Alpha software.** The NAPI interface may change between versions.
//!
//! # Quick example (Node.js)
//!
//! ```js
//! const { connect, serve } = require('alknet-napi');
//!
//! // Client: open a duplex SSH stream
//! const stream = await connect({
//! server: "example.com:22",
//! transport: "tcp",
//! identity: "/path/to/key",
//! });
//! await stream.write(Buffer.from("hello"));
//! const data = await stream.read(1024);
//! await stream.close();
//! ```
#[allow(unused_imports)]
#[macro_use]
extern crate napi_derive;
mod connect;
mod serve;

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
//! # alknet-secret
//!
//! BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM
//! encryption for external credentials, and the `SecretProtocol` irpc service.
//!
//! This crate is the only component that holds the master seed phrase. All other
//! crates request derived keys through the `SecretProtocol` irpc service or the
//! `SecretServiceHandle` local API.
//!
//! ## Crate Independence
//!
//! alknet-secret does **not** depend on alknet-core or alknet-storage. Per ADR-027,
//! it is fully independent. The `EncryptedData` wire format is shared with
//! alknet-storage by type-level compatibility, not a crate dependency.
//!
//! ## Security Model
//!
//! The seed phrase is never persisted to disk. It is entered at startup or via
//! `Unlock` and held only in `Zeroize`-protected RAM (ADR-038). `Lock` purges
//! the seed and all cached derived keys.
//!
//! ## Module Organization
//!
//! - [`mnemonic`] — BIP39 mnemonic generation, validation, and seed derivation
//! - [`derivation`] — SLIP-0010 Ed25519 HD key derivation and path constants
//! - [`encryption`] — AES-256-GCM encrypt/decrypt and `EncryptedData` type
//! - [`protocol`] — `SecretProtocol` irpc service enum, `DerivedKey`, `KeyType`
//! - [`service`] — `SecretService` implementation with Unlock/Lock lifecycle
//! - [`ethereum`] — BIP-0032 secp256k1 HD key derivation (behind `secp256k1` feature)
pub mod cache;
pub mod derivation;
pub mod encryption;
pub mod mnemonic;
pub mod protocol;
pub mod service;
#[cfg(feature = "secp256k1")]
pub mod ethereum;
// Re-export primary public API
pub use cache::CacheConfig;
pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
pub use encryption::{EncryptedData, EncryptionError};
pub use mnemonic::{Language, Mnemonic, Seed};
pub use protocol::{DerivedKey, KeyType, SecretMessage, SecretProtocol};
pub use service::{SecretService, SecretServiceActor, SecretServiceError, SecretServiceHandle};

View File

@@ -1,310 +0,0 @@
//! SecretProtocol irpc service definition and associated types.
//!
//! This module defines the `SecretProtocol` enum for irpc-based inter-service
//! communication. The protocol supports unlock/lock lifecycle, key derivation,
//! and encryption/decryption operations.
//!
//! # Protocol Operation
//!
//! The SecretProtocol follows a lifecycle: the service starts in a **locked**
//! state where no derivation or encryption operations are possible. The `Unlock`
//! call loads the seed into memory (derived from the mnemonic passphrase). After
//! that, derive and encrypt/decrypt operations are available. The `Lock` call
//! purges the seed and all cached keys.
//!
//! # Wire Format
//!
//! For local (in-process) calls, the protocol uses tokio channels directly.
//! For remote (in-cluster) calls, the protocol is serialized with postcard.
//! For cross-node (call protocol) exposure, the service is wrapped in an
//! operation that serializes to JSON.
use std::fmt;
use irpc::rpc_requests;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use zeroize::Zeroize;
use crate::encryption::EncryptedData;
/// The type of a derived key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
/// Ed25519 keypair (SLIP-0010 derivation).
Ed25519,
/// AES-256-GCM symmetric key (derived from seed, used for external credential encryption).
Aes256Gcm,
/// secp256k1 keypair (BIP-0032 derivation, for Ethereum signing).
Secp256k1,
}
/// A derived key pair (private key + public key).
///
/// The private key is sensitive material that is zeroized on drop (ADR-038).
/// This type is **not** `Clone` — it is move-only. Consumers receive a
/// `DerivedKey` by value and must zeroize it when done (handled automatically
/// by `#[zeroize(drop)]`).
///
/// Serialization redacts the `private_key` field for human-readable formats
/// (JSON) for safety, showing `"[REDACTED]"` instead of the key bytes. For
/// binary formats (postcard, used by irpc), the actual bytes are serialized
/// so that remote communication works correctly. Deserialization always reads
/// the full bytes.
#[derive(Zeroize, Deserialize)]
#[zeroize(drop)]
pub struct DerivedKey {
/// The type of key that was derived.
#[zeroize(skip)]
pub key_type: KeyType,
/// The private key bytes (sensitive — zeroized on drop).
#[zeroize]
#[serde(deserialize_with = "deserialize_private_key")]
pub private_key: Vec<u8>,
/// The public key bytes.
#[zeroize(skip)]
pub public_key: Vec<u8>,
}
fn deserialize_private_key<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
Vec::<u8>::deserialize(d)
}
impl fmt::Debug for DerivedKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DerivedKey")
.field("key_type", &self.key_type)
.field("private_key", &"[REDACTED]")
.field("public_key", &self.public_key)
.finish()
}
}
impl Serialize for DerivedKey {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
if s.is_human_readable() {
let mut state = s.serialize_struct("DerivedKey", 3)?;
state.serialize_field("key_type", &self.key_type)?;
state.serialize_field("private_key", "[REDACTED]")?;
state.serialize_field("public_key", &self.public_key)?;
state.end()
} else {
let mut state = s.serialize_struct("DerivedKey", 3)?;
state.serialize_field("key_type", &self.key_type)?;
state.serialize_field("private_key", &self.private_key)?;
state.serialize_field("public_key", &self.public_key)?;
state.end()
}
}
}
/// SecretProtocol service definition.
///
/// This is the irpc protocol enum that defines all secret service operations.
/// The `#[rpc_requests]` macro generates:
/// - **`SecretMessage`**: message enum with `WithChannels` wrappers for each variant
/// - **`Channels<SecretProtocol>`** impls for each wrapper type
/// - **`From`** impls for protocol enum and message enum conversions
/// - **`Service`** and **`RemoteService`** trait impls for remote dispatch
///
/// # State Requirements
///
/// All operations except `Unlock` require the service to be in an **unlocked**
/// state. Calling derive/encrypt/decrypt on a locked service returns an error.
#[rpc_requests(message = SecretMessage, no_spans)]
#[derive(Debug, Serialize, Deserialize)]
pub enum SecretProtocol {
/// Derive an Ed25519 keypair at the given path.
///
/// Path format: `m/74'/0'/0'/0'` (SLIP-0010 hardened-only notation).
/// Returns a `DerivedKey` with `KeyType::Ed25519`.
#[rpc(tx = irpc::channel::oneshot::Sender<Result<DerivedKey, crate::service::SecretServiceError>>)]
#[wrap(DeriveEd25519)]
DeriveEd25519 {
/// SLIP-0010 derivation path (e.g., "m/74'/0'/0'/0'").
path: String,
},
/// Derive an AES-256-GCM encryption key at the given path.
///
/// The default encryption path is `m/74'/2'/0'/0'`.
/// Returns a `DerivedKey` with `KeyType::Aes256Gcm`.
#[rpc(tx = irpc::channel::oneshot::Sender<Result<DerivedKey, crate::service::SecretServiceError>>)]
#[wrap(DeriveEncryptionKey)]
DeriveEncryptionKey {
/// SLIP-0010 derivation path for the encryption key.
path: String,
},
/// Derive a secp256k1 (Ethereum) keypair at the given path.
///
/// The default Ethereum path is `m/44'/60'/0'/0/0`.
/// Returns a `DerivedKey` with `KeyType::Secp256k1`.
#[rpc(tx = irpc::channel::oneshot::Sender<Result<DerivedKey, crate::service::SecretServiceError>>)]
#[wrap(DeriveEthereumKey)]
DeriveEthereumKey {
/// BIP-0032 derivation path (e.g., "m/44'/60'/0'/0/0").
path: String,
},
/// Derive a deterministic password at the given path.
///
/// Path format: `m/74'/1'/0'/{hash}'` (SLIP-0010 hardened notation).
/// The `length` parameter controls the output length.
#[rpc(tx = irpc::channel::oneshot::Sender<Result<Vec<u8>, crate::service::SecretServiceError>>)]
#[wrap(DerivePassword)]
DerivePassword {
/// SLIP-0010 derivation path for the password.
path: String,
/// Desired password length in bytes.
length: usize,
},
/// Encrypt plaintext using a derived encryption key.
///
/// The key is derived at the path `m/74'/2'/0'/0'` with the given version.
/// Returns an `EncryptedData` blob suitable for storage.
#[rpc(tx = irpc::channel::oneshot::Sender<Result<EncryptedData, crate::service::SecretServiceError>>)]
#[wrap(Encrypt)]
Encrypt {
/// The plaintext string to encrypt.
plaintext: String,
/// The key version for rotation tracking.
key_version: u32,
},
/// Decrypt an `EncryptedData` blob back to plaintext.
///
/// The key is derived from the seed at the path indicated by the key version.
#[rpc(tx = irpc::channel::oneshot::Sender<Result<String, crate::service::SecretServiceError>>)]
#[wrap(Decrypt)]
Decrypt {
/// The encrypted data blob to decrypt.
encrypted: EncryptedData,
},
/// Lock the service, purging the seed and all cached derived keys.
///
/// After locking, no derive/encrypt/decrypt operations are possible
/// until `Unlock` is called again. Calls `zeroize()` on all sensitive
/// material (ADR-038).
#[rpc(tx = irpc::channel::oneshot::Sender<Result<(), crate::service::SecretServiceError>>)]
#[wrap(Lock)]
Lock,
/// Unlock the service with a BIP39 mnemonic and optional passphrase.
///
/// The mnemonic is the space-separated BIP39 word list. The passphrase is
/// the optional BIP39 password extension (the "25th word"). After unlocking,
/// derive and encrypt/decrypt operations are available.
#[rpc(tx = irpc::channel::oneshot::Sender<Result<(), crate::service::SecretServiceError>>)]
#[wrap(Unlock)]
Unlock {
/// The BIP39 mnemonic phrase (space-separated word list).
mnemonic: String,
/// Optional BIP39 passphrase (the "25th word" password extension).
passphrase: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_key() -> DerivedKey {
DerivedKey {
key_type: KeyType::Ed25519,
private_key: vec![0xABu8; 32],
public_key: vec![0xCDu8; 32],
}
}
#[test]
fn test_derived_key_debug_redacts_private_key() {
let key = make_test_key();
let debug_output = format!("{:?}", key);
assert!(
!debug_output.contains("AB"),
"Debug must not leak private_key bytes"
);
assert!(
debug_output.contains("[REDACTED]"),
"Debug must show [REDACTED] for private_key"
);
assert!(debug_output.contains("Ed25519"), "Debug must show key_type");
}
#[test]
fn test_derived_key_serialize_redacts_private_key_json() {
let key = make_test_key();
let json = serde_json::to_string(&key).unwrap();
assert!(
!json.contains("AB"),
"JSON must not contain private_key bytes"
);
assert!(
json.contains("[REDACTED]"),
"JSON must show [REDACTED] for private_key"
);
assert!(json.contains("Ed25519"), "JSON must contain key_type");
}
#[test]
fn test_derived_key_serialize_preserves_bytes_postcard() {
let key = make_test_key();
let bytes = postcard::to_allocvec(&key).unwrap();
let restored: DerivedKey = postcard::from_bytes(&bytes).unwrap();
assert_eq!(
restored.private_key,
vec![0xABu8; 32],
"postcard must preserve private_key bytes"
);
assert_eq!(
restored.public_key,
vec![0xCDu8; 32],
"postcard must preserve public_key bytes"
);
}
#[test]
fn test_derived_key_deserialize_preserves_bytes() {
let key = make_test_key();
let bytes = postcard::to_allocvec(&key.private_key).unwrap();
let restored: Vec<u8> = postcard::from_bytes(&bytes).unwrap();
assert_eq!(
restored,
vec![0xABu8; 32],
"Deserialization must preserve private_key bytes"
);
}
#[test]
fn test_derived_key_zeroize_on_drop() {
let key = DerivedKey {
key_type: KeyType::Aes256Gcm,
private_key: vec![0xFFu8; 32],
public_key: vec![0x00u8; 32],
};
drop(key);
}
#[test]
fn test_derived_key_not_clone() {
let key = make_test_key();
let _moved = key;
}
#[test]
fn test_derived_key_zeroize_method_overwrites_private_key() {
let mut key = make_test_key();
assert_ne!(key.private_key, vec![0u8; 32]);
assert!(!key.private_key.is_empty());
key.zeroize();
assert!(
key.private_key.is_empty(),
"zeroize() must clear the private_key Vec"
);
}
}

View File

@@ -1,13 +1,13 @@
[package] [package]
name = "alknet-secret" name = "alknet-vault"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM encryption, and SecretProtocol irpc service for alknet" description = "Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM encryption for securing provider keys, credentials, and identity material"
repository.workspace = true repository.workspace = true
[lib] [lib]
name = "alknet_secret" name = "alknet_vault"
[features] [features]
default = [] default = []
@@ -25,11 +25,7 @@ zeroize = { version = "1", features = ["derive"] }
hmac = "0.12" hmac = "0.12"
rand = "0.8" rand = "0.8"
base64 = "0.22" base64 = "0.22"
irpc = { workspace = true }
irpc-derive = { workspace = true }
tokio = { version = "1", features = ["sync", "rt", "macros"] }
secp256k1 = { version = "0.29", optional = true } secp256k1 = { version = "0.29", optional = true }
[dev-dependencies] [dev-dependencies]
hex = "0.4" hex = "0.4"
postcard = { version = "1", features = ["alloc"] }

View File

@@ -1,4 +1,4 @@
//! TTL-based key cache with LRU eviction for SecretService. //! TTL-based key cache with LRU eviction for VaultService.
//! //!
//! The `KeyCache` stores derived key material keyed by derivation path. Entries //! The `KeyCache` stores derived key material keyed by derivation path. Entries
//! expire after a configurable TTL (default: 1 hour) and are evicted lazily on //! expire after a configurable TTL (default: 1 hour) and are evicted lazily on
@@ -206,6 +206,84 @@ impl Default for KeyCache {
} }
} }
#[cfg(test)]
mod drop_tracker {
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use super::*;
struct DropTrackedKey {
flag: Arc<AtomicBool>,
bytes: Vec<u8>,
}
impl DropTrackedKey {
fn new(flag: &Arc<AtomicBool>) -> Self {
Self {
flag: flag.clone(),
bytes: vec![0xABu8; 32],
}
}
}
impl Drop for DropTrackedKey {
fn drop(&mut self) {
for b in self.bytes.iter_mut() {
*b = 0;
}
self.flag.store(true, Ordering::SeqCst);
}
}
#[test]
fn test_hashmap_clear_drops_values_triggering_drop_impls() {
let flag1 = Arc::new(AtomicBool::new(false));
let flag2 = Arc::new(AtomicBool::new(false));
let mut map: HashMap<String, DropTrackedKey> = HashMap::new();
map.insert("path1".to_string(), DropTrackedKey::new(&flag1));
map.insert("path2".to_string(), DropTrackedKey::new(&flag2));
assert!(!flag1.load(Ordering::SeqCst));
assert!(!flag2.load(Ordering::SeqCst));
map.clear();
assert!(flag1.load(Ordering::SeqCst));
assert!(flag2.load(Ordering::SeqCst));
assert!(map.is_empty());
}
#[test]
fn test_hashmap_remove_drops_value_triggering_drop_impl() {
let flag = Arc::new(AtomicBool::new(false));
let mut map: HashMap<String, DropTrackedKey> = HashMap::new();
map.insert("path1".to_string(), DropTrackedKey::new(&flag));
assert!(!flag.load(Ordering::SeqCst));
map.remove("path1");
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn test_hashmap_insert_replace_drops_old_value() {
let flag_old = Arc::new(AtomicBool::new(false));
let mut map: HashMap<String, DropTrackedKey> = HashMap::new();
map.insert("path1".to_string(), DropTrackedKey::new(&flag_old));
assert!(!flag_old.load(Ordering::SeqCst));
let flag_new = Arc::new(AtomicBool::new(false));
map.insert("path1".to_string(), DropTrackedKey::new(&flag_new));
assert!(flag_old.load(Ordering::SeqCst));
assert!(!flag_new.load(Ordering::SeqCst));
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -231,8 +309,10 @@ mod tests {
#[test] #[test]
fn test_cache_expired_entry_evicted_on_access() { fn test_cache_expired_entry_evicted_on_access() {
let mut config = CacheConfig::default(); let config = CacheConfig {
config.ttl = Duration::from_millis(1); ttl: Duration::from_millis(1),
..Default::default()
};
let mut cache = KeyCache::new(config); let mut cache = KeyCache::new(config);
cache.insert("m/74'/0'/0'/0'", make_cached_key(KeyType::Ed25519)); cache.insert("m/74'/0'/0'/0'", make_cached_key(KeyType::Ed25519));
@@ -245,8 +325,10 @@ mod tests {
#[test] #[test]
fn test_cache_lru_eviction() { fn test_cache_lru_eviction() {
let mut config = CacheConfig::default(); let config = CacheConfig {
config.max_entries = 3; max_entries: 3,
..Default::default()
};
let mut cache = KeyCache::new(config); let mut cache = KeyCache::new(config);
@@ -267,8 +349,10 @@ mod tests {
#[test] #[test]
fn test_cache_lru_access_reorders() { fn test_cache_lru_access_reorders() {
let mut config = CacheConfig::default(); let config = CacheConfig {
config.max_entries = 3; max_entries: 3,
..Default::default()
};
let mut cache = KeyCache::new(config); let mut cache = KeyCache::new(config);
@@ -303,8 +387,10 @@ mod tests {
#[test] #[test]
fn test_evict_expired_removes_only_expired() { fn test_evict_expired_removes_only_expired() {
let mut config = CacheConfig::default(); let config = CacheConfig {
config.ttl = Duration::from_millis(10); ttl: Duration::from_millis(10),
..Default::default()
};
let mut cache = KeyCache::new(config); let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519)); cache.insert("path1", make_cached_key(KeyType::Ed25519));
@@ -336,4 +422,56 @@ mod tests {
assert_eq!(entry.private_key, vec![3u8; 32]); assert_eq!(entry.private_key, vec![3u8; 32]);
assert_eq!(cache.len(), 1); assert_eq!(cache.len(), 1);
} }
#[test]
fn test_lru_eviction_drops_evicted_cached_key() {
let mut config = CacheConfig::default();
config.max_entries = 2;
let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
assert_eq!(cache.len(), 2);
cache.insert("path3", make_cached_key(KeyType::Secp256k1));
assert_eq!(cache.len(), 2);
assert!(cache.get("path1").is_none());
assert!(cache.get("path2").is_some());
assert!(cache.get("path3").is_some());
}
#[test]
fn test_ttl_expiry_evicts_entry_on_access() {
let mut config = CacheConfig::default();
config.ttl = Duration::from_millis(1);
let mut cache = KeyCache::new(config);
cache.insert("path1", make_cached_key(KeyType::Ed25519));
assert_eq!(cache.len(), 1);
std::thread::sleep(Duration::from_millis(5));
assert!(cache.get("path1").is_none());
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
#[test]
fn test_clear_removes_all_entries_and_empties_cache() {
let mut cache = KeyCache::with_defaults();
cache.insert("path1", make_cached_key(KeyType::Ed25519));
cache.insert("path2", make_cached_key(KeyType::Aes256Gcm));
cache.insert("path3", make_cached_key(KeyType::Secp256k1));
assert_eq!(cache.len(), 3);
cache.clear();
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
assert!(cache.get("path1").is_none());
assert!(cache.get("path2").is_none());
assert!(cache.get("path3").is_none());
}
} }

View File

@@ -24,7 +24,7 @@ type HmacSha512 = Hmac<Sha512>;
/// Well-known derivation path constants for alknet key material. /// Well-known derivation path constants for alknet key material.
/// ///
/// These paths are defined once and referenced by both the secret service and /// These paths are defined once and referenced by both the vault service and
/// external consumers that need to request specific key types. /// external consumers that need to request specific key types.
#[allow(non_snake_case)] #[allow(non_snake_case)]
pub mod PATHS { pub mod PATHS {
@@ -101,8 +101,8 @@ impl ExtendedPrivKey {
/// # Example /// # Example
/// ///
/// ``` /// ```
/// use alknet_secret::derivation::{derive_path_from_seed, PATHS}; /// use alknet_vault::derivation::{derive_path_from_seed, PATHS};
/// use alknet_secret::mnemonic::Mnemonic; /// use alknet_vault::mnemonic::Mnemonic;
/// ///
/// let mnemonic = Mnemonic::generate(24).unwrap(); /// let mnemonic = Mnemonic::generate(24).unwrap();
/// let seed = mnemonic.to_seed(None); /// let seed = mnemonic.to_seed(None);

View File

@@ -37,6 +37,7 @@ use aes_gcm::{
aead::{Aead, KeyInit}, aead::{Aead, KeyInit},
Aes256Gcm, Nonce, Aes256Gcm, Nonce,
}; };
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use zeroize::Zeroize; use zeroize::Zeroize;
@@ -52,7 +53,7 @@ pub const CURRENT_KEY_VERSION: u32 = 1;
/// ///
/// The Rust `EncryptedData` is a superset of the TypeScript `EncryptedDataSchema` /// The Rust `EncryptedData` is a superset of the TypeScript `EncryptedDataSchema`
/// from `@alkdev/storage`. Migration path: re-encrypt TypeScript-encrypted data /// from `@alkdev/storage`. Migration path: re-encrypt TypeScript-encrypted data
/// using the Rust secret service with a new key version. /// using the Rust vault with a new key version.
/// ///
/// See OQ-SVC-03 for the compatibility tracking. /// See OQ-SVC-03 for the compatibility tracking.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -129,12 +130,14 @@ pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result<EncryptedData, En
let cipher = Aes256Gcm::new_from_slice(&key.key_bytes) let cipher = Aes256Gcm::new_from_slice(&key.key_bytes)
.map_err(|e| EncryptionError::Encryption(format!("invalid key length: {e}")))?; .map_err(|e| EncryptionError::Encryption(format!("invalid key length: {e}")))?;
// Generate random IV (12 bytes for AES-GCM) // Generate random IV (12 bytes for AES-GCM) using OsRng CSPRNG
let iv_bytes: [u8; 12] = rand::random(); let mut iv_bytes = [0u8; 12];
OsRng.fill_bytes(&mut iv_bytes);
let nonce = Nonce::from_slice(&iv_bytes); let nonce = Nonce::from_slice(&iv_bytes);
// TODO(Phase B): Use salt in HKDF-based key derivation // TODO(Phase B): Use salt in HKDF-based key derivation
let salt_bytes: [u8; 32] = rand::random(); let mut salt_bytes = [0u8; 32];
OsRng.fill_bytes(&mut salt_bytes);
let ciphertext = cipher let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes()) .encrypt(nonce, plaintext.as_bytes())

View File

@@ -138,8 +138,8 @@ fn derive_child(
/// # Example /// # Example
/// ///
/// ```ignore /// ```ignore
/// use alknet_secret::ethereum::derive_secp256k1_path; /// use alknet_vault::ethereum::derive_secp256k1_path;
/// use alknet_secret::derivation::PATHS; /// use alknet_vault::derivation::PATHS;
/// ///
/// let key = derive_secp256k1_path(seed, PATHS::ETHEREUM).unwrap(); /// let key = derive_secp256k1_path(seed, PATHS::ETHEREUM).unwrap();
/// assert_eq!(key.private_key().len(), 32); /// assert_eq!(key.private_key().len(), 32);

View File

@@ -0,0 +1,49 @@
//! # alknet-vault
//!
//! Local key vault: BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation,
//! AES-256-GCM encryption for securing provider keys, credentials, and identity material.
//!
//! This crate is the only component that holds the master seed phrase. The CLI binary
//! unlocks the vault at startup and injects derived/decrypted material into operation
//! contexts. Other crates never access the vault directly — they receive keys through
//! their operation context or via the call protocol.
//!
//! ## Crate Independence
//!
//! alknet-vault does **not** depend on alknet-core or any other alknet crate. It is
//! fully independent and usable in contexts where QUIC networking doesn't exist (CLI
//! tools, test harnesses, WASM key derivation).
//!
//! ## Security Model
//!
//! The seed phrase is never persisted to disk. It is entered at startup or via
//! `Unlock` and held only in `Zeroize`-protected RAM (ADR-038). `Lock` purges
//! the seed and all cached derived keys.
//!
//! ## Module Organization
//!
//! - [`mnemonic`] — BIP39 mnemonic generation, validation, and seed derivation
//! - [`derivation`] — SLIP-0010 Ed25519 HD key derivation and path constants
//! - [`encryption`] — AES-256-GCM encrypt/decrypt and `EncryptedData` type
//! - [`protocol`] — `DerivedKey` and `KeyType` (return types from vault methods)
//! - [`service`] — `VaultServiceHandle` runtime API with Unlock/Lock lifecycle
//! - [`ethereum`] — BIP-0032 secp256k1 HD key derivation (behind `secp256k1` feature)
pub mod cache;
pub mod derivation;
pub mod encryption;
pub mod mnemonic;
pub mod protocol;
pub mod service;
#[cfg(feature = "secp256k1")]
pub mod ethereum;
// Re-export primary public API
pub use cache::CacheConfig;
pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
pub use encryption::CURRENT_KEY_VERSION;
pub use encryption::{EncryptedData, EncryptionError, EncryptionKey};
pub use mnemonic::{Language, Mnemonic, Seed};
pub use protocol::{DerivedKey, KeyType};
pub use service::{VaultServiceError, VaultServiceHandle};

View File

@@ -0,0 +1,157 @@
//! Vault key types: `DerivedKey` and `KeyType`.
//!
//! The vault's dispatch is direct method calls on `VaultServiceHandle`
//! (ADR-025). The types defined here — `DerivedKey`, `KeyType` — are the
//! return types from those methods. There is no `VaultProtocol` enum, no
//! `VaultMessage`, no `VaultServiceActor`, and no remote dispatch capability.
//!
//! The vault is **local-only by construction**. If remote vault access is
//! ever needed, it requires a separate crate that wraps the vault and adds
//! remote transport + auth (ADR-025, OQ-021).
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use zeroize::Zeroize;
/// The type of a derived key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
/// Ed25519 keypair (SLIP-0010 derivation).
Ed25519,
/// AES-256-GCM symmetric key (derived from seed, used for external credential encryption).
Aes256Gcm,
/// secp256k1 keypair (BIP-0032 derivation, for Ethereum signing).
Secp256k1,
}
/// A derived key pair (private key + public key).
///
/// The private key is sensitive material that is zeroized on drop (ADR-038).
/// This type is **not** `Clone` — it is move-only. Consumers receive a
/// `DerivedKey` by value and must zeroize it when done (handled automatically
/// by `#[zeroize(drop)]`).
///
/// Serialization redacts the `private_key` field for human-readable formats
/// (JSON) for safety, showing `"[REDACTED]"` instead of the key bytes.
/// Deserialization always reads the full bytes.
#[derive(Zeroize, Deserialize)]
#[zeroize(drop)]
pub struct DerivedKey {
/// The type of key that was derived.
#[zeroize(skip)]
pub key_type: KeyType,
/// The private key bytes (sensitive — zeroized on drop).
#[zeroize]
#[serde(deserialize_with = "deserialize_private_key")]
pub private_key: Vec<u8>,
/// The public key bytes.
#[zeroize(skip)]
pub public_key: Vec<u8>,
}
fn deserialize_private_key<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
Vec::<u8>::deserialize(d)
}
impl fmt::Debug for DerivedKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DerivedKey")
.field("key_type", &self.key_type)
.field("private_key", &"[REDACTED]")
.field("public_key", &self.public_key)
.finish()
}
}
impl Serialize for DerivedKey {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
if s.is_human_readable() {
let mut state = s.serialize_struct("DerivedKey", 3)?;
state.serialize_field("key_type", &self.key_type)?;
state.serialize_field("private_key", "[REDACTED]")?;
state.serialize_field("public_key", &self.public_key)?;
state.end()
} else {
let mut state = s.serialize_struct("DerivedKey", 3)?;
state.serialize_field("key_type", &self.key_type)?;
state.serialize_field("private_key", &self.private_key)?;
state.serialize_field("public_key", &self.public_key)?;
state.end()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_key() -> DerivedKey {
DerivedKey {
key_type: KeyType::Ed25519,
private_key: vec![0xABu8; 32],
public_key: vec![0xCDu8; 32],
}
}
#[test]
fn test_derived_key_debug_redacts_private_key() {
let key = make_test_key();
let debug_output = format!("{:?}", key);
assert!(
!debug_output.contains("AB"),
"Debug must not leak private_key bytes"
);
assert!(
debug_output.contains("[REDACTED]"),
"Debug must show [REDACTED] for private_key"
);
assert!(debug_output.contains("Ed25519"), "Debug must show key_type");
}
#[test]
fn test_derived_key_serialize_redacts_private_key_json() {
let key = make_test_key();
let json = serde_json::to_string(&key).unwrap();
assert!(
!json.contains("AB"),
"JSON must not contain private_key bytes"
);
assert!(
json.contains("[REDACTED]"),
"JSON must show [REDACTED] for private_key"
);
assert!(json.contains("Ed25519"), "JSON must contain key_type");
}
#[test]
fn test_derived_key_zeroize_on_drop() {
let key = DerivedKey {
key_type: KeyType::Aes256Gcm,
private_key: vec![0xFFu8; 32],
public_key: vec![0x00u8; 32],
};
drop(key);
}
#[test]
fn test_derived_key_not_clone() {
let key = make_test_key();
let _moved = key;
}
#[test]
fn test_derived_key_zeroize_method_overwrites_private_key() {
let mut key = make_test_key();
assert_ne!(key.private_key, vec![0u8; 32]);
assert!(!key.private_key.is_empty());
key.zeroize();
assert!(
key.private_key.is_empty(),
"zeroize() must clear the private_key Vec"
);
}
}

View File

@@ -1,8 +1,8 @@
//! SecretService implementation with Unlock/Lock lifecycle. //! VaultServiceHandle — the sole runtime API for the vault.
//! //!
//! The `SecretService` is the primary runtime interface for key management. //! The `VaultServiceHandle` wraps the vault's state in an
//! It holds the master seed in `Zeroize`-protected memory and provides methods //! `Arc<std::sync::RwLock<>>` and provides direct, synchronous method calls
//! for the Unlock/Lock lifecycle, key derivation, and encryption/decryption. //! for the unlock/lock lifecycle, key derivation, and encryption/decryption.
//! //!
//! # Lifecycle //! # Lifecycle
//! //!
@@ -14,7 +14,7 @@
//! → cache empty (keys derived on demand) //! → cache empty (keys derived on demand)
//! //!
//! DeriveEd25519/DeriveEncryptionKey/Encrypt/Decrypt //! DeriveEd25519/DeriveEncryptionKey/Encrypt/Decrypt
//! → require unlocked state (ServiceLocked error if locked) //! → require unlocked state (VaultLocked error if locked)
//! → derive key, return result //! → derive key, return result
//! → optionally cache derived key //! → optionally cache derived key
//! //!
@@ -22,53 +22,46 @@
//! → zeroize all cached derived keys //! → zeroize all cached derived keys
//! → zeroize seed //! → zeroize seed
//! → drop all sensitive material //! → drop all sensitive material
//! → service returns to locked state //! → vault returns to locked state
//! ``` //! ```
//! //!
//! # Dispatch Paths //! # Dispatch
//! //!
//! There are two ways to interact with the secret service: //! The vault uses **direct method calls** on `VaultServiceHandle` — no actor,
//! //! no message enum, no channels, no serialization (ADR-025). The handle is
//! 1. **Local (in-process)**: `SecretServiceHandle` wraps `SecretServiceInner` //! `Arc<std::sync::RwLock<VaultServiceInner>>` — clone it, share it, call
//! behind `Arc<RwLock<>>` and provides direct method calls without serialization. //! methods directly. All methods are synchronous (no `async`, no `.await`).
//! 2. **Remote (in-cluster)**: `SecretServiceActor` processes `SecretMessage` //! The vault does not depend on `tokio` (ADR-025).
//! variants from an mpsc channel and dispatches to the handle methods.
//! //!
//! # Assembly //! # Assembly
//! //!
//! The `SecretService` is assembled by the CLI binary or NAPI layer. Per ADR-027, //! The `VaultServiceHandle` is assembled by the CLI binary. The CLI unlocks
//! alknet-core never sees the secret service directly — it is wired through the //! the vault at startup and injects derived/decrypted material into operation
//! `OperationEnv` dispatch mechanism. For minimal deployments, no secret service //! contexts. No handler crate accesses the vault directly — they receive keys
//! is available (the `SecretStoreCredentialProvider` returns `None`). //! through their operation context or via the call protocol.
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine; use base64::Engine;
use irpc::WithChannels;
use serde::{Deserialize, Serialize};
use crate::cache::{CacheConfig, CachedKey, KeyCache}; use crate::cache::{CacheConfig, CachedKey, KeyCache};
use crate::derivation::{self, DerivationError, PATHS}; use crate::derivation::{self, DerivationError, PATHS};
use crate::encryption::{self, EncryptedData, EncryptionKey}; use crate::encryption::{self, EncryptedData, EncryptionKey};
use crate::mnemonic::{Language, Mnemonic, Seed}; use crate::mnemonic::{Language, Mnemonic, Seed};
use crate::protocol::{
Decrypt, DeriveEd25519, DeriveEncryptionKey, DeriveEthereumKey, DerivePassword, Encrypt,
SecretMessage, SecretProtocol, Unlock,
};
use crate::protocol::{DerivedKey, KeyType}; use crate::protocol::{DerivedKey, KeyType};
/// Handle to a running SecretService for local (in-process) use. /// Handle to a running VaultService for local (in-process) use.
/// ///
/// This is the primary API for local secret operations. It wraps the /// This is the primary API for local secret operations. It wraps the
/// service state in an `Arc<RwLock<>>` for thread-safe access. /// service state in an `Arc<RwLock<>>` for thread-safe access.
#[derive(Clone)] #[derive(Clone)]
pub struct SecretServiceHandle { pub struct VaultServiceHandle {
inner: Arc<RwLock<SecretServiceInner>>, inner: Arc<RwLock<VaultServiceInner>>,
} }
/// Internal state of the secret service. /// Internal state of the secret service.
struct SecretServiceInner { struct VaultServiceInner {
/// The mnemonic phrase, if unlocked. None if locked. /// The mnemonic phrase, if unlocked. None if locked.
mnemonic: Option<Mnemonic>, mnemonic: Option<Mnemonic>,
/// The master seed, if unlocked. None if locked. /// The master seed, if unlocked. None if locked.
@@ -79,12 +72,12 @@ struct SecretServiceInner {
cache: KeyCache, cache: KeyCache,
} }
/// Errors that can occur during secret service operations. /// Errors that can occur during vault operations.
#[derive(Debug, thiserror::Error, Serialize, Deserialize)] #[derive(Debug, thiserror::Error)]
pub enum SecretServiceError { pub enum VaultServiceError {
#[error("service is locked; call Unlock first")] #[error("vault is locked; call Unlock first")]
ServiceLocked, VaultLocked,
#[error("service is already unlocked")] #[error("vault is already unlocked")]
AlreadyUnlocked, AlreadyUnlocked,
#[error("mnemonic error: {0}")] #[error("mnemonic error: {0}")]
Mnemonic(String), Mnemonic(String),
@@ -98,34 +91,34 @@ pub enum SecretServiceError {
UnsupportedKeyType, UnsupportedKeyType,
} }
impl From<crate::mnemonic::MnemonicError> for SecretServiceError { impl From<crate::mnemonic::MnemonicError> for VaultServiceError {
fn from(e: crate::mnemonic::MnemonicError) -> Self { fn from(e: crate::mnemonic::MnemonicError) -> Self {
SecretServiceError::Mnemonic(e.to_string()) VaultServiceError::Mnemonic(e.to_string())
} }
} }
impl From<DerivationError> for SecretServiceError { impl From<DerivationError> for VaultServiceError {
fn from(e: DerivationError) -> Self { fn from(e: DerivationError) -> Self {
SecretServiceError::Derivation(e.to_string()) VaultServiceError::Derivation(e.to_string())
} }
} }
impl From<encryption::EncryptionError> for SecretServiceError { impl From<encryption::EncryptionError> for VaultServiceError {
fn from(e: encryption::EncryptionError) -> Self { fn from(e: encryption::EncryptionError) -> Self {
SecretServiceError::Encryption(e.to_string()) VaultServiceError::Encryption(e.to_string())
} }
} }
impl SecretServiceHandle { impl VaultServiceHandle {
/// Create a new SecretServiceHandle in the locked state with default cache config. /// Create a new VaultServiceHandle in the locked state with default cache config.
pub fn new() -> Self { pub fn new() -> Self {
Self::with_cache_config(CacheConfig::default()) Self::with_cache_config(CacheConfig::default())
} }
/// Create a new SecretServiceHandle with the given cache configuration. /// Create a new VaultServiceHandle with the given cache configuration.
pub fn with_cache_config(config: CacheConfig) -> Self { pub fn with_cache_config(config: CacheConfig) -> Self {
Self { Self {
inner: Arc::new(RwLock::new(SecretServiceInner { inner: Arc::new(RwLock::new(VaultServiceInner {
mnemonic: None, mnemonic: None,
seed: None, seed: None,
unlocked: false, unlocked: false,
@@ -138,10 +131,10 @@ impl SecretServiceHandle {
/// ///
/// The passphrase is the BIP39 password (may be empty string for none). /// The passphrase is the BIP39 password (may be empty string for none).
/// After unlocking, derive and encrypt/decrypt operations are available. /// After unlocking, derive and encrypt/decrypt operations are available.
pub fn unlock(&self, phrase: &str, passphrase: Option<&str>) -> Result<(), SecretServiceError> { pub fn unlock(&self, phrase: &str, passphrase: Option<&str>) -> Result<(), VaultServiceError> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if inner.unlocked { if inner.unlocked {
return Err(SecretServiceError::AlreadyUnlocked); return Err(VaultServiceError::AlreadyUnlocked);
} }
let mnemonic = Mnemonic::from_phrase(phrase, Language::English)?; let mnemonic = Mnemonic::from_phrase(phrase, Language::English)?;
@@ -157,10 +150,10 @@ impl SecretServiceHandle {
/// ///
/// Returns the generated mnemonic phrase. Store this phrase securely — /// Returns the generated mnemonic phrase. Store this phrase securely —
/// it is the root of trust for all derived keys. /// it is the root of trust for all derived keys.
pub fn unlock_new(&self, word_count: usize) -> Result<String, SecretServiceError> { pub fn unlock_new(&self, word_count: usize) -> Result<String, VaultServiceError> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if inner.unlocked { if inner.unlocked {
return Err(SecretServiceError::AlreadyUnlocked); return Err(VaultServiceError::AlreadyUnlocked);
} }
let mnemonic = Mnemonic::generate(word_count)?; let mnemonic = Mnemonic::generate(word_count)?;
@@ -192,10 +185,10 @@ impl SecretServiceHandle {
} }
/// Derive an Ed25519 keypair at the given path. /// Derive an Ed25519 keypair at the given path.
pub fn derive_ed25519(&self, path: &str) -> Result<DerivedKey, SecretServiceError> { pub fn derive_ed25519(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if !inner.unlocked { if !inner.unlocked {
return Err(SecretServiceError::ServiceLocked); return Err(VaultServiceError::VaultLocked);
} }
if let Some(cached) = inner.cache.get(path) { if let Some(cached) = inner.cache.get(path) {
@@ -206,10 +199,7 @@ impl SecretServiceHandle {
}); });
} }
let seed = inner let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
.seed
.as_ref()
.ok_or(SecretServiceError::ServiceLocked)?;
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
let private_key = key.private_key().to_vec(); let private_key = key.private_key().to_vec();
let public_key = key.public_key().to_vec(); let public_key = key.public_key().to_vec();
@@ -223,10 +213,10 @@ impl SecretServiceHandle {
} }
/// Derive an AES-256-GCM encryption key at the given path. /// Derive an AES-256-GCM encryption key at the given path.
pub fn derive_encryption_key(&self, path: &str) -> Result<DerivedKey, SecretServiceError> { pub fn derive_encryption_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if !inner.unlocked { if !inner.unlocked {
return Err(SecretServiceError::ServiceLocked); return Err(VaultServiceError::VaultLocked);
} }
if let Some(cached) = inner.cache.get(path) { if let Some(cached) = inner.cache.get(path) {
@@ -237,10 +227,7 @@ impl SecretServiceHandle {
}); });
} }
let seed = inner let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
.seed
.as_ref()
.ok_or(SecretServiceError::ServiceLocked)?;
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
let private_key = key.private_key().to_vec(); let private_key = key.private_key().to_vec();
let public_key = key.public_key().to_vec(); let public_key = key.public_key().to_vec();
@@ -258,12 +245,12 @@ impl SecretServiceHandle {
/// Uses BIP-0032 derivation (HMAC-SHA512 with "Bitcoin seed") when the /// Uses BIP-0032 derivation (HMAC-SHA512 with "Bitcoin seed") when the
/// `secp256k1` feature is enabled. Returns `UnsupportedKeyType` when the /// `secp256k1` feature is enabled. Returns `UnsupportedKeyType` when the
/// feature is disabled. /// feature is disabled.
pub fn derive_ethereum_key(&self, path: &str) -> Result<DerivedKey, SecretServiceError> { pub fn derive_ethereum_key(&self, path: &str) -> Result<DerivedKey, VaultServiceError> {
#[cfg(feature = "secp256k1")] #[cfg(feature = "secp256k1")]
{ {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if !inner.unlocked { if !inner.unlocked {
return Err(SecretServiceError::ServiceLocked); return Err(VaultServiceError::VaultLocked);
} }
if let Some(cached) = inner.cache.get(path) { if let Some(cached) = inner.cache.get(path) {
@@ -274,10 +261,7 @@ impl SecretServiceHandle {
}); });
} }
let seed = inner let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
.seed
.as_ref()
.ok_or(SecretServiceError::ServiceLocked)?;
let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?; let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?;
let private_key = key.private_key().to_vec(); let private_key = key.private_key().to_vec();
@@ -295,23 +279,16 @@ impl SecretServiceHandle {
#[cfg(not(feature = "secp256k1"))] #[cfg(not(feature = "secp256k1"))]
{ {
let _ = path; let _ = path;
Err(SecretServiceError::UnsupportedKeyType) Err(VaultServiceError::UnsupportedKeyType)
} }
} }
pub fn derive_password( pub fn derive_password(&self, path: &str, length: usize) -> Result<Vec<u8>, VaultServiceError> {
&self,
path: &str,
length: usize,
) -> Result<Vec<u8>, SecretServiceError> {
let inner = self.inner.read().unwrap(); let inner = self.inner.read().unwrap();
if !inner.unlocked { if !inner.unlocked {
return Err(SecretServiceError::ServiceLocked); return Err(VaultServiceError::VaultLocked);
} }
let seed = inner let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
.seed
.as_ref()
.ok_or(SecretServiceError::ServiceLocked)?;
let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?;
let private_key = key.private_key(); let private_key = key.private_key();
@@ -324,7 +301,7 @@ impl SecretServiceHandle {
&self, &self,
path: &str, path: &str,
length: usize, length: usize,
) -> Result<String, SecretServiceError> { ) -> Result<String, VaultServiceError> {
let bytes = self.derive_password(path, length)?; let bytes = self.derive_password(path, length)?;
Ok(URL_SAFE_NO_PAD.encode(&bytes)) Ok(URL_SAFE_NO_PAD.encode(&bytes))
} }
@@ -336,19 +313,16 @@ impl SecretServiceHandle {
&self, &self,
plaintext: &str, plaintext: &str,
key_version: u32, key_version: u32,
) -> Result<EncryptedData, SecretServiceError> { ) -> Result<EncryptedData, VaultServiceError> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if !inner.unlocked { if !inner.unlocked {
return Err(SecretServiceError::ServiceLocked); return Err(VaultServiceError::VaultLocked);
} }
let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) { let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) {
cached.private_key.clone() cached.private_key.clone()
} else { } else {
let seed = inner let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
.seed
.as_ref()
.ok_or(SecretServiceError::ServiceLocked)?;
let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?;
let pk = derived.private_key().to_vec(); let pk = derived.private_key().to_vec();
let pubk = derived.public_key().to_vec(); let pubk = derived.public_key().to_vec();
@@ -363,19 +337,16 @@ impl SecretServiceHandle {
} }
/// Decrypt an EncryptedData blob using the derived encryption key. /// Decrypt an EncryptedData blob using the derived encryption key.
pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, SecretServiceError> { pub fn decrypt(&self, encrypted: &EncryptedData) -> Result<String, VaultServiceError> {
let mut inner = self.inner.write().unwrap(); let mut inner = self.inner.write().unwrap();
if !inner.unlocked { if !inner.unlocked {
return Err(SecretServiceError::ServiceLocked); return Err(VaultServiceError::VaultLocked);
} }
let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) { let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) {
cached.private_key.clone() cached.private_key.clone()
} else { } else {
let seed = inner let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?;
.seed
.as_ref()
.ok_or(SecretServiceError::ServiceLocked)?;
let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?;
let pk = derived.private_key().to_vec(); let pk = derived.private_key().to_vec();
let pubk = derived.public_key().to_vec(); let pubk = derived.public_key().to_vec();
@@ -390,182 +361,25 @@ impl SecretServiceHandle {
} }
} }
impl Default for SecretServiceHandle { impl Default for VaultServiceHandle {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
} }
/// The SecretService manages the lifecycle of the master seed and provides
/// secret operations. This is the type used by the irpc service handler.
///
/// For local (in-process) use, prefer `SecretServiceHandle` which wraps
/// this in thread-safe locks.
pub struct SecretService {
handle: SecretServiceHandle,
}
impl SecretService {
/// Create a new SecretService in the locked state.
pub fn new() -> Self {
Self {
handle: SecretServiceHandle::new(),
}
}
/// Get a handle for local (in-process) use.
pub fn handle(&self) -> &SecretServiceHandle {
&self.handle
}
}
impl Default for SecretService {
fn default() -> Self {
Self::new()
}
}
/// Actor that processes `SecretMessage` variants and dispatches to `SecretServiceHandle`.
///
/// The actor runs as a `tokio::task`, receives messages from an mpsc channel,
/// dispatches to the handle methods, and sends responses through oneshot channels.
///
/// # Usage
///
/// ```ignore
/// let handle = SecretServiceHandle::new();
/// let (client, actor) = SecretServiceActor::spawn(handle);
/// tokio::task::spawn(actor.run(rx));
/// // Use client to send messages
/// ```
pub struct SecretServiceActor {
handle: SecretServiceHandle,
}
impl SecretServiceActor {
/// Create a new actor wrapping the given handle.
pub fn new(handle: SecretServiceHandle) -> Self {
Self { handle }
}
/// Run the actor message loop, processing `SecretMessage` variants.
///
/// This method runs until the receiver channel is closed. Each message
/// variant is dispatched to the corresponding `SecretServiceHandle` method
/// and the response is sent through the oneshot channel embedded in the message.
pub async fn run(mut self, mut rx: tokio::sync::mpsc::Receiver<SecretMessage>) {
while let Some(msg) = rx.recv().await {
self.handle_message(msg);
}
}
/// Spawn the actor as a `tokio::task` and return a `Client<SecretProtocol>` for sending messages.
///
/// The actor runs on a tokio task and processes messages from the mpsc channel.
/// The returned `Client<SecretProtocol>` can be used to send `SecretMessage` variants
/// to the actor.
pub fn spawn(
handle: SecretServiceHandle,
) -> (irpc::Client<SecretProtocol>, SecretServiceActor) {
let (tx, rx) = tokio::sync::mpsc::channel(64);
let client = irpc::Client::local(tx);
let actor = Self::new(handle.clone());
tokio::task::spawn(actor.run(rx));
(client, Self::new(handle))
}
/// Handle a single `SecretMessage` by dispatching to the appropriate handle method.
fn handle_message(&mut self, msg: SecretMessage) {
match msg {
SecretMessage::DeriveEd25519(msg) => {
let WithChannels { inner, tx, .. } = msg;
let DeriveEd25519 { path } = inner;
let result = self.handle.derive_ed25519(&path);
tokio::spawn(async move {
let _ = tx.send(result).await;
});
}
SecretMessage::DeriveEncryptionKey(msg) => {
let WithChannels { inner, tx, .. } = msg;
let DeriveEncryptionKey { path } = inner;
let result = self.handle.derive_encryption_key(&path);
tokio::spawn(async move {
let _ = tx.send(result).await;
});
}
SecretMessage::DeriveEthereumKey(msg) => {
let WithChannels { inner, tx, .. } = msg;
let DeriveEthereumKey { path } = inner;
let result = self.handle.derive_ethereum_key(&path);
tokio::spawn(async move {
let _ = tx.send(result).await;
});
}
SecretMessage::DerivePassword(msg) => {
let WithChannels { inner, tx, .. } = msg;
let DerivePassword { path, length } = inner;
let result = self.handle.derive_password(&path, length);
tokio::spawn(async move {
let _ = tx.send(result).await;
});
}
SecretMessage::Encrypt(msg) => {
let WithChannels { inner, tx, .. } = msg;
let Encrypt {
plaintext,
key_version,
} = inner;
let result = self.handle.encrypt(&plaintext, key_version);
tokio::spawn(async move {
let _ = tx.send(result).await;
});
}
SecretMessage::Decrypt(msg) => {
let WithChannels { inner, tx, .. } = msg;
let Decrypt { encrypted } = inner;
let result = self.handle.decrypt(&encrypted);
tokio::spawn(async move {
let _ = tx.send(result).await;
});
}
SecretMessage::Lock(msg) => {
let WithChannels { inner: _, tx, .. } = msg;
self.handle.lock();
tokio::spawn(async move {
let _ = tx.send(Ok(())).await;
});
}
SecretMessage::Unlock(msg) => {
let WithChannels { inner, tx, .. } = msg;
let Unlock {
mnemonic,
passphrase,
} = inner;
let result = self.handle.unlock(&mnemonic, passphrase.as_deref());
tokio::spawn(async move {
let _ = tx.send(result).await;
});
}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::protocol::Lock;
use irpc::channel::oneshot;
use irpc::WithChannels;
#[test] #[test]
fn test_service_starts_locked() { fn test_service_starts_locked() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
assert!(!service.is_unlocked()); assert!(!service.is_unlocked());
} }
#[test] #[test]
fn test_unlock_new_generates_mnemonic() { fn test_unlock_new_generates_mnemonic() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let phrase = service.unlock_new(24).unwrap(); let phrase = service.unlock_new(24).unwrap();
assert!(!phrase.is_empty()); assert!(!phrase.is_empty());
assert!(service.is_unlocked()); assert!(service.is_unlocked());
@@ -573,7 +387,7 @@ mod tests {
#[test] #[test]
fn test_lock_purges_state() { fn test_lock_purges_state() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
assert!(service.is_unlocked()); assert!(service.is_unlocked());
@@ -583,21 +397,21 @@ mod tests {
#[test] #[test]
fn test_derive_on_locked_fails() { fn test_derive_on_locked_fails() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let result = service.derive_ed25519(PATHS::IDENTITY); let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn test_encrypt_on_locked_fails() { fn test_encrypt_on_locked_fails() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let result = service.encrypt("secret", 1); let result = service.encrypt("secret", 1);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn test_full_lifecycle() { fn test_full_lifecycle() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
assert!(!service.is_unlocked()); assert!(!service.is_unlocked());
@@ -617,7 +431,7 @@ mod tests {
#[test] #[test]
fn test_unlock_with_known_phrase() { fn test_unlock_with_known_phrase() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let phrase = service.unlock_new(24).unwrap(); let phrase = service.unlock_new(24).unwrap();
service.lock(); service.lock();
@@ -628,7 +442,7 @@ mod tests {
#[test] #[test]
fn test_double_unlock_fails() { fn test_double_unlock_fails() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let result = service.unlock_new(12); let result = service.unlock_new(12);
@@ -637,7 +451,7 @@ mod tests {
#[test] #[test]
fn test_encrypt_decrypt_lifecycle() { fn test_encrypt_decrypt_lifecycle() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let plaintext = "my-api-key-12345"; let plaintext = "my-api-key-12345";
@@ -651,7 +465,7 @@ mod tests {
#[test] #[test]
fn test_derive_password_deterministic() { fn test_derive_password_deterministic() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let path = "m/74'/1'/0'/12345'"; let path = "m/74'/1'/0'/12345'";
@@ -662,7 +476,7 @@ mod tests {
#[test] #[test]
fn test_derive_password_different_paths() { fn test_derive_password_different_paths() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let pw_a = service.derive_password("m/74'/1'/0'/100'", 16).unwrap(); let pw_a = service.derive_password("m/74'/1'/0'/100'", 16).unwrap();
@@ -675,7 +489,7 @@ mod tests {
#[test] #[test]
fn test_derive_password_length_truncation() { fn test_derive_password_length_truncation() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let path = "m/74'/1'/0'/999'"; let path = "m/74'/1'/0'/999'";
@@ -693,14 +507,14 @@ mod tests {
#[test] #[test]
fn test_derive_password_locked_error() { fn test_derive_password_locked_error() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let result = service.derive_password("m/74'/1'/0'/1'", 16); let result = service.derive_password("m/74'/1'/0'/1'", 16);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked))); assert!(matches!(result, Err(VaultServiceError::VaultLocked)));
} }
#[test] #[test]
fn test_derive_password_string_base64url() { fn test_derive_password_string_base64url() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let path = "m/74'/1'/0'/42'"; let path = "m/74'/1'/0'/42'";
@@ -722,7 +536,7 @@ mod tests {
#[cfg(feature = "secp256k1")] #[cfg(feature = "secp256k1")]
#[test] #[test]
fn test_derive_ethereum_key_bip32() { fn test_derive_ethereum_key_bip32() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap(); let key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap();
@@ -734,7 +548,7 @@ mod tests {
#[cfg(feature = "secp256k1")] #[cfg(feature = "secp256k1")]
#[test] #[test]
fn test_ethereum_key_differs_from_ed25519() { fn test_ethereum_key_differs_from_ed25519() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let eth_key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap(); let eth_key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap();
@@ -746,19 +560,16 @@ mod tests {
#[cfg(not(feature = "secp256k1"))] #[cfg(not(feature = "secp256k1"))]
#[test] #[test]
fn test_derive_ethereum_key_unsupported_without_feature() { fn test_derive_ethereum_key_unsupported_without_feature() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let result = service.derive_ethereum_key(PATHS::ETHEREUM); let result = service.derive_ethereum_key(PATHS::ETHEREUM);
assert!(matches!( assert!(matches!(result, Err(VaultServiceError::UnsupportedKeyType)));
result,
Err(SecretServiceError::UnsupportedKeyType)
));
} }
#[test] #[test]
fn test_cache_hit_avoids_re_derivation() { fn test_cache_hit_avoids_re_derivation() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap(); let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
@@ -773,7 +584,7 @@ mod tests {
#[test] #[test]
fn test_cache_miss_derives_and_caches() { fn test_cache_miss_derives_and_caches() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
assert_eq!(service.inner.read().unwrap().cache.len(), 0); assert_eq!(service.inner.read().unwrap().cache.len(), 0);
@@ -786,7 +597,7 @@ mod tests {
#[test] #[test]
fn test_expired_entry_evicted_on_access() { fn test_expired_entry_evicted_on_access() {
let config = crate::cache::CacheConfig::new(std::time::Duration::from_millis(5), 64); let config = crate::cache::CacheConfig::new(std::time::Duration::from_millis(5), 64);
let service = SecretServiceHandle::with_cache_config(config); let service = VaultServiceHandle::with_cache_config(config);
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap(); let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
@@ -802,7 +613,7 @@ mod tests {
#[test] #[test]
fn test_lru_eviction_when_over_max_entries() { fn test_lru_eviction_when_over_max_entries() {
let config = crate::cache::CacheConfig::new(std::time::Duration::from_secs(3600), 2); let config = crate::cache::CacheConfig::new(std::time::Duration::from_secs(3600), 2);
let service = SecretServiceHandle::with_cache_config(config); let service = VaultServiceHandle::with_cache_config(config);
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
service.derive_ed25519(PATHS::IDENTITY).unwrap(); service.derive_ed25519(PATHS::IDENTITY).unwrap();
@@ -820,7 +631,7 @@ mod tests {
#[test] #[test]
fn test_lock_clears_all_cache_entries() { fn test_lock_clears_all_cache_entries() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
service.derive_ed25519(PATHS::IDENTITY).unwrap(); service.derive_ed25519(PATHS::IDENTITY).unwrap();
@@ -834,7 +645,7 @@ mod tests {
#[test] #[test]
fn test_encrypt_decrypt_uses_cached_encryption_key() { fn test_encrypt_decrypt_uses_cached_encryption_key() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let plaintext = "cached-encryption-test"; let plaintext = "cached-encryption-test";
@@ -847,76 +658,10 @@ mod tests {
assert_eq!(service.inner.read().unwrap().cache.len(), 1); assert_eq!(service.inner.read().unwrap().cache.len(), 1);
} }
#[tokio::test]
async fn test_actor_unlock_responds_successfully() {
let handle = SecretServiceHandle::new();
let (tx, rx) = tokio::sync::mpsc::channel(64);
let actor = SecretServiceActor::new(handle);
tokio::task::spawn(actor.run(rx));
let (resp_tx, resp_rx) = oneshot::channel();
let msg = SecretMessage::Unlock(WithChannels::from((
Unlock {
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
passphrase: None,
},
resp_tx,
)));
tx.send(msg).await.unwrap();
let result = resp_rx.await.unwrap();
assert!(result.is_ok(), "Unlock via actor must succeed");
}
#[tokio::test]
async fn test_actor_derive_ed25519_returns_key() {
let handle = SecretServiceHandle::new();
handle.unlock_new(24).unwrap();
let (tx, rx) = tokio::sync::mpsc::channel(64);
let actor = SecretServiceActor::new(handle);
tokio::task::spawn(actor.run(rx));
let (resp_tx, resp_rx) = oneshot::channel();
let msg = SecretMessage::DeriveEd25519(WithChannels::from((
DeriveEd25519 {
path: PATHS::IDENTITY.to_string(),
},
resp_tx,
)));
tx.send(msg).await.unwrap();
let result = resp_rx.await.unwrap();
assert!(result.is_ok(), "DeriveEd25519 via actor must succeed");
let key = result.unwrap();
assert!(
!key.private_key.is_empty(),
"DerivedKey must have private_key"
);
assert_eq!(key.key_type, KeyType::Ed25519);
}
#[tokio::test]
async fn test_actor_lock_clears_state() {
let handle = SecretServiceHandle::new();
handle.unlock_new(24).unwrap();
let (tx, rx) = tokio::sync::mpsc::channel(64);
let actor = SecretServiceActor::new(handle.clone());
tokio::task::spawn(actor.run(rx));
let (resp_tx, resp_rx): (oneshot::Sender<Result<(), SecretServiceError>>, _) =
oneshot::channel();
let msg = SecretMessage::Lock(WithChannels::from((Lock, resp_tx)));
tx.send(msg).await.unwrap();
let result = resp_rx.await.unwrap();
assert!(result.is_ok(), "Lock via actor must succeed");
assert!(!handle.is_unlocked(), "Handle must be locked after Lock");
}
#[test] #[test]
fn test_unlock_with_passphrase_produces_different_seed() { fn test_unlock_with_passphrase_produces_different_seed() {
let service_a = SecretServiceHandle::new(); let service_a = VaultServiceHandle::new();
let service_b = SecretServiceHandle::new(); let service_b = VaultServiceHandle::new();
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
@@ -943,30 +688,4 @@ mod tests {
"Unlock with None passphrase must produce same seed as another None passphrase unlock" "Unlock with None passphrase must produce same seed as another None passphrase unlock"
); );
} }
#[tokio::test]
async fn test_actor_unlock_with_passphrase() {
let handle = SecretServiceHandle::new();
let (tx, rx) = tokio::sync::mpsc::channel(64);
let actor = SecretServiceActor::new(handle);
tokio::task::spawn(actor.run(rx));
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let (resp_tx, resp_rx) = oneshot::channel();
let msg = SecretMessage::Unlock(WithChannels::from((
Unlock {
mnemonic: mnemonic.to_string(),
passphrase: Some("TREZOR".to_string()),
},
resp_tx,
)));
tx.send(msg).await.unwrap();
let result = resp_rx.await.unwrap();
assert!(
result.is_ok(),
"Unlock with passphrase via actor must succeed"
);
}
} }

View File

@@ -3,33 +3,33 @@
//! These tests verify that SLIP-0010 derivation produces correct results //! These tests verify that SLIP-0010 derivation produces correct results
//! against known test vectors and that path constants produce expected key types. //! against known test vectors and that path constants produce expected key types.
use alknet_secret::derivation::PATHS; use alknet_vault::derivation::PATHS;
use alknet_secret::service::SecretServiceHandle; use alknet_vault::service::VaultServiceHandle;
#[test] #[test]
fn test_identity_key_derivation() { fn test_identity_key_derivation() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let _phrase = service.unlock_new(24).unwrap(); let _phrase = service.unlock_new(24).unwrap();
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap(); let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(key.key_type, alknet_secret::protocol::KeyType::Ed25519); assert_eq!(key.key_type, alknet_vault::protocol::KeyType::Ed25519);
assert!(!key.private_key.is_empty()); assert!(!key.private_key.is_empty());
assert!(!key.public_key.is_empty()); assert!(!key.public_key.is_empty());
} }
#[test] #[test]
fn test_encryption_key_derivation() { fn test_encryption_key_derivation() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let key = service.derive_encryption_key(PATHS::ENCRYPTION).unwrap(); let key = service.derive_encryption_key(PATHS::ENCRYPTION).unwrap();
assert_eq!(key.key_type, alknet_secret::protocol::KeyType::Aes256Gcm); assert_eq!(key.key_type, alknet_vault::protocol::KeyType::Aes256Gcm);
} }
#[test] #[test]
fn test_deterministic_derivation() { fn test_deterministic_derivation() {
// Same seed + same path = same key // Same seed + same path = same key
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let phrase = service.unlock_new(24).unwrap(); let phrase = service.unlock_new(24).unwrap();
let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap(); let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
@@ -46,7 +46,7 @@ fn test_deterministic_derivation() {
#[test] #[test]
fn test_different_paths_different_keys() { fn test_different_paths_different_keys() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let identity_key = service.derive_ed25519(PATHS::IDENTITY).unwrap(); let identity_key = service.derive_ed25519(PATHS::IDENTITY).unwrap();

View File

@@ -3,12 +3,12 @@
//! These tests verify round-trip encryption, key version handling, //! These tests verify round-trip encryption, key version handling,
//! and wire format compatibility. //! and wire format compatibility.
use alknet_secret::encryption::CURRENT_KEY_VERSION; use alknet_vault::encryption::CURRENT_KEY_VERSION;
use alknet_secret::service::SecretServiceHandle; use alknet_vault::service::VaultServiceHandle;
#[test] #[test]
fn test_encrypt_decrypt_round_trip_via_service() { fn test_encrypt_decrypt_round_trip_via_service() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let plaintext = "sk-proj-abc123xyz789"; let plaintext = "sk-proj-abc123xyz789";
@@ -21,7 +21,7 @@ fn test_encrypt_decrypt_round_trip_via_service() {
#[test] #[test]
fn test_encrypt_produces_different_ciphertext_each_time() { fn test_encrypt_produces_different_ciphertext_each_time() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let plaintext = "same input different ciphertexts"; let plaintext = "same input different ciphertexts";
@@ -38,7 +38,7 @@ fn test_encrypt_produces_different_ciphertext_each_time() {
#[test] #[test]
fn test_encrypted_data_serialization() { fn test_encrypted_data_serialization() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let plaintext = "test serialization"; let plaintext = "test serialization";
@@ -52,7 +52,7 @@ fn test_encrypted_data_serialization() {
assert!(json.contains("data")); assert!(json.contains("data"));
// Verify round-trip through JSON // Verify round-trip through JSON
let deserialized: alknet_secret::encryption::EncryptedData = let deserialized: alknet_vault::encryption::EncryptedData =
serde_json::from_str(&json).unwrap(); serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, encrypted); assert_eq!(deserialized, encrypted);
} }

View File

@@ -1,21 +1,21 @@
//! Integration tests for the SecretService lifecycle. //! Integration tests for the VaultService lifecycle.
//! //!
//! These tests verify the unlock/lock lifecycle, error conditions, //! These tests verify the unlock/lock lifecycle, error conditions,
//! and that the service correctly manages state transitions. //! and that the vault correctly manages state transitions.
use alknet_secret::derivation::PATHS; use alknet_vault::derivation::PATHS;
use alknet_secret::service::{SecretServiceError, SecretServiceHandle}; use alknet_vault::service::{VaultServiceError, VaultServiceHandle};
#[test] #[test]
fn test_full_lifecycle() { fn test_full_lifecycle() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
// Starts locked // Starts locked
assert!(!service.is_unlocked()); assert!(!service.is_unlocked());
// Can't derive while locked // Can't derive while locked
let result = service.derive_ed25519(PATHS::IDENTITY); let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked))); assert!(matches!(result, Err(VaultServiceError::VaultLocked)));
// Unlock // Unlock
let phrase = service.unlock_new(24).unwrap(); let phrase = service.unlock_new(24).unwrap();
@@ -32,12 +32,12 @@ fn test_full_lifecycle() {
// Can't derive again // Can't derive again
let result = service.derive_ed25519(PATHS::IDENTITY); let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked))); assert!(matches!(result, Err(VaultServiceError::VaultLocked)));
} }
#[test] #[test]
fn test_unlock_with_known_phrase() { fn test_unlock_with_known_phrase() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
// Generate a phrase // Generate a phrase
let phrase = service.unlock_new(24).unwrap(); let phrase = service.unlock_new(24).unwrap();
@@ -53,16 +53,16 @@ fn test_unlock_with_known_phrase() {
#[test] #[test]
fn test_double_unlock_fails() { fn test_double_unlock_fails() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let result = service.unlock_new(12); let result = service.unlock_new(12);
assert!(matches!(result, Err(SecretServiceError::AlreadyUnlocked))); assert!(matches!(result, Err(VaultServiceError::AlreadyUnlocked)));
} }
#[test] #[test]
fn test_lock_when_already_locked_is_noop() { fn test_lock_when_already_locked_is_noop() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
assert!(!service.is_unlocked()); assert!(!service.is_unlocked());
// Lock on already-locked service is a no-op // Lock on already-locked service is a no-op
@@ -72,7 +72,7 @@ fn test_lock_when_already_locked_is_noop() {
#[test] #[test]
fn test_encrypt_decrypt_lifecycle() { fn test_encrypt_decrypt_lifecycle() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
let plaintext = "my-api-key-12345"; let plaintext = "my-api-key-12345";
@@ -83,12 +83,12 @@ fn test_encrypt_decrypt_lifecycle() {
// After lock, can't decrypt // After lock, can't decrypt
service.lock(); service.lock();
let result = service.decrypt(&encrypted); let result = service.decrypt(&encrypted);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked))); assert!(matches!(result, Err(VaultServiceError::VaultLocked)));
} }
#[test] #[test]
fn test_multiple_derive_paths_succeed() { fn test_multiple_derive_paths_succeed() {
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
service.unlock_new(24).unwrap(); service.unlock_new(24).unwrap();
// All standard paths should work // All standard paths should work

View File

@@ -17,10 +17,10 @@
//! byte-for-byte matching against SLIP-0010 raw hex, since the crate's internal //! byte-for-byte matching against SLIP-0010 raw hex, since the crate's internal
//! representation handles clamping differently. //! representation handles clamping differently.
use alknet_secret::derivation::{derive_path_from_seed, PATHS}; use alknet_vault::derivation::{derive_path_from_seed, PATHS};
use alknet_secret::encryption::{decrypt, encrypt, EncryptionKey, CURRENT_KEY_VERSION}; use alknet_vault::encryption::{decrypt, encrypt, EncryptionKey, CURRENT_KEY_VERSION};
use alknet_secret::mnemonic::{Language, Mnemonic}; use alknet_vault::mnemonic::{Language, Mnemonic};
use alknet_secret::protocol::KeyType; use alknet_vault::protocol::KeyType;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// BIP39 Test Vectors // BIP39 Test Vectors
@@ -291,7 +291,7 @@ fn test_aes256gcm_known_key_encrypt_decrypt() {
]; ];
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = b"hello, alknet secret service!"; let plaintext = b"hello, alknet vault!";
// Encrypt with known key and nonce // Encrypt with known key and nonce
let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()).unwrap(); let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()).unwrap();
@@ -396,13 +396,13 @@ fn test_alknet_encryption_path_regression() {
assert_ne!(key.private_key(), identity.private_key()); assert_ne!(key.private_key(), identity.private_key());
} }
/// Verify that the SecretServiceHandle produces keys consistent with /// Verify that the VaultServiceHandle produces keys consistent with
/// direct derivation (integration test). /// direct derivation (integration test).
#[test] #[test]
fn test_service_derive_matches_direct_derivation() { fn test_service_derive_matches_direct_derivation() {
use alknet_secret::service::SecretServiceHandle; use alknet_vault::service::VaultServiceHandle;
let service = SecretServiceHandle::new(); let service = VaultServiceHandle::new();
let phrase = service.unlock_new(24).unwrap(); let phrase = service.unlock_new(24).unwrap();
// Derive via service (which uses Mnemonic + Seed internally) // Derive via service (which uses Mnemonic + Seed internally)

View File

@@ -1,29 +0,0 @@
[package]
name = "alknet"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "CLI binary for Alknet: self-hostable SSH tunnel tool with pluggable transports"
repository.workspace = true
[[bin]]
name = "alknet"
path = "src/main.rs"
[features]
default = ["tls", "iroh"]
tls = ["alknet-core/tls", "dep:rustls-pemfile", "dep:rustls-pki-types"]
iroh = ["alknet-core/iroh", "dep:iroh", "dep:url"]
acme = ["alknet-core/acme", "dep:rustls-acme", "dep:rustls", "tls"]
[dependencies]
alknet-core = { path = "../alknet-core" }
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
iroh = { version = "0.34", optional = true }
url = { version = "2", optional = true }
rustls-acme = { version = "0.12", optional = true }
rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] }
rustls-pemfile = { version = "2", optional = true }
rustls-pki-types = { version = "1", optional = true }

View File

@@ -1,548 +0,0 @@
//! # alknet
//!
//! CLI binary for [Alknet](https://git.alk.dev/alkdev/alknet), a self-hostable SSH-based tunnel
//! tool. Provides `alknet connect` (client) and `alknet serve` (server) subcommands with
//! pluggable transports (TCP, TLS, iroh).
//!
//! > **Alpha software.** See `alknet-core` for library usage.
use std::net::SocketAddr;
use std::process;
use std::sync::Arc;
use alknet_core::auth::keys::KeySource;
use alknet_core::client::{ConnectOptions, TransportMode};
use alknet_core::server::{ServeOptions, ServeTransportMode, Server};
#[cfg(feature = "iroh")]
use alknet_core::transport::IrohTransport;
use alknet_core::transport::TcpTransport;
#[cfg(feature = "tls")]
use alknet_core::transport::TlsTransport;
use alknet_core::transport::Transport;
use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(name = "alknet", version, about = "Alknet SSH tunnel tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(
about = "Connect to an alknet server and start a SOCKS5 proxy / port forwarding session"
)]
Connect {
#[arg(
long,
help = "TCP/TLS server address (required for tcp/tls transport)",
env = "ALKNET_SERVER"
)]
server: Option<String>,
#[arg(
long,
help = "iroh endpoint ID, base58-encoded (required for iroh transport)"
)]
peer: Option<String>,
#[arg(long, value_enum, default_value = "tcp", help = "Transport mode")]
transport: TransportModeArg,
#[arg(long, help = "SSH private key path", env = "ALKNET_IDENTITY")]
identity: Option<String>,
#[arg(long, default_value = "127.0.0.1:1080", help = "SOCKS5 listen address")]
socks5: String,
#[arg(long, action = clap::ArgAction::Append, help = "Port forward spec (repeatable, e.g. 5432:db:5432)")]
forward: Vec<String>,
#[arg(long, action = clap::ArgAction::Append, help = "Remote port forward spec (repeatable)")]
remote_forward: Vec<String>,
#[arg(long, help = "Upstream proxy URL (socks5:// or http://)")]
proxy: Option<String>,
#[arg(long, help = "iroh relay URL")]
iroh_relay: Option<String>,
#[arg(long, help = "SNI hostname for TLS")]
tls_server_name: Option<String>,
#[arg(long, help = "Accept self-signed TLS certs")]
insecure: bool,
},
#[command(about = "Start the alknet server (accept SSH connections)")]
Serve {
#[arg(long, help = "SSH host key path (required)")]
key: String,
#[arg(long, help = "Authorized keys file path")]
authorized_keys: Option<String>,
#[arg(long, help = "CA public key for certificate authority auth")]
cert_authority: Option<String>,
#[arg(
long,
value_enum,
default_value = "tcp",
help = "Transport mode (tcp, tls, iroh)"
)]
transport: ServeTransportModeArg,
#[arg(
long,
default_value = "0.0.0.0:22",
help = "Listen address for TCP/TLS"
)]
listen: String,
#[arg(long, help = "TLS certificate path (manual)")]
tls_cert: Option<String>,
#[arg(long, help = "TLS private key path (manual)")]
tls_key: Option<String>,
#[arg(long, help = "ACME auto-cert domain")]
acme_domain: Option<String>,
#[arg(
long,
help = "Serve fake nginx 404 to non-SSH connections (requires --transport tls)"
)]
stealth: bool,
#[arg(long, help = "Outbound proxy URL (socks5:// or http://)")]
proxy: Option<String>,
#[arg(long, help = "iroh relay server URL")]
iroh_relay: Option<String>,
#[arg(
long,
default_value_t = 0,
help = "Max concurrent connections per IP (0 = unlimited)"
)]
max_connections_per_ip: usize,
#[arg(
long,
default_value_t = 10,
help = "Max auth failures before disconnect"
)]
max_auth_attempts: usize,
},
}
#[derive(Clone, Debug, ValueEnum)]
enum TransportModeArg {
Tcp,
Tls,
Iroh,
}
impl From<TransportModeArg> for TransportMode {
fn from(val: TransportModeArg) -> Self {
match val {
TransportModeArg::Tcp => TransportMode::Tcp,
TransportModeArg::Tls => TransportMode::Tls,
TransportModeArg::Iroh => TransportMode::Iroh,
}
}
}
#[derive(Clone, Debug, ValueEnum)]
enum ServeTransportModeArg {
Tcp,
Tls,
Iroh,
}
impl From<ServeTransportModeArg> for ServeTransportMode {
fn from(val: ServeTransportModeArg) -> Self {
match val {
ServeTransportModeArg::Tcp => ServeTransportMode::Tcp,
ServeTransportModeArg::Tls => ServeTransportMode::Tls,
ServeTransportModeArg::Iroh => ServeTransportMode::Iroh,
}
}
}
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
eprintln!("error: {e}");
process::exit(1);
}
}
async fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Connect {
server,
peer,
transport,
identity,
socks5,
forward,
remote_forward,
proxy,
iroh_relay,
tls_server_name,
insecure,
} => {
run_connect(
server,
peer,
transport,
identity,
socks5,
forward,
remote_forward,
proxy,
iroh_relay,
tls_server_name,
insecure,
)
.await
}
Commands::Serve {
key,
authorized_keys,
cert_authority,
transport,
listen,
tls_cert,
tls_key,
acme_domain,
stealth,
proxy,
iroh_relay,
max_connections_per_ip,
max_auth_attempts,
} => {
run_serve(
key,
authorized_keys,
cert_authority,
transport,
listen,
tls_cert,
tls_key,
acme_domain,
stealth,
proxy,
iroh_relay,
max_connections_per_ip,
max_auth_attempts,
)
.await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_connect(
server: Option<String>,
peer: Option<String>,
transport: TransportModeArg,
identity: Option<String>,
socks5: String,
forward: Vec<String>,
remote_forward: Vec<String>,
proxy: Option<String>,
iroh_relay: Option<String>,
tls_server_name: Option<String>,
insecure: bool,
) -> Result<()> {
let identity_val = identity
.ok_or_else(|| anyhow!("--identity is required (or set ALKNET_IDENTITY env var)"))?;
let key_source = KeySource::File(identity_val.into());
let transport_mode: TransportMode = transport.into();
if proxy.is_some() && matches!(transport_mode, TransportMode::Tcp) {
eprintln!("warning: --proxy with --transport tcp is effectively a no-op (TCP transport is already a direct connection); use the SOCKS5 server instead");
}
let mut opts = ConnectOptions::new(key_source)
.transport_mode(transport_mode.clone())
.socks5_addr(&socks5);
if let Some(ref s) = server {
opts = opts.server(s);
}
if let Some(ref p) = peer {
opts = opts.peer(p);
}
for fwd in &forward {
opts = opts.forward(fwd);
}
for rfwd in &remote_forward {
opts = opts.remote_forward(rfwd);
}
if let Some(ref p) = proxy {
opts = opts.proxy(p);
}
if let Some(ref r) = iroh_relay {
opts = opts.iroh_relay(r);
}
if let Some(ref n) = tls_server_name {
opts = opts.tls_server_name(n);
}
if insecure {
opts = opts.insecure(true);
}
opts.validate().map_err(|e| anyhow!("{e}"))?;
match transport_mode {
TransportMode::Tcp => {
let addr: SocketAddr = server
.as_deref()
.ok_or_else(|| anyhow!("--server is required for tcp transport"))?
.parse()
.map_err(|e| anyhow!("invalid server address: {e}"))?;
let t = Arc::new(TcpTransport::new(addr));
connect_and_run(opts, t).await
}
TransportMode::Tls => {
#[cfg(not(feature = "tls"))]
{
Err(anyhow!(
"TLS transport is not available (alknet-core built without 'tls' feature)"
))
}
#[cfg(feature = "tls")]
{
let addr: SocketAddr = server
.as_deref()
.ok_or_else(|| anyhow!("--server is required for tls transport"))?
.parse()
.map_err(|e| anyhow!("invalid server address: {e}"))?;
let mut t = TlsTransport::new(addr);
if let Some(ref n) = tls_server_name {
t = t.with_server_name(n);
}
t = t.with_insecure(insecure);
let t = Arc::new(t);
connect_and_run(opts, t).await
}
}
TransportMode::Iroh => {
#[cfg(not(feature = "iroh"))]
{
Err(anyhow!(
"iroh transport is not available (alknet-core built without 'iroh' feature)"
))
}
#[cfg(feature = "iroh")]
{
use iroh::{NodeId, RelayUrl};
let node_id_str = peer
.as_deref()
.ok_or_else(|| anyhow!("--peer is required for iroh transport"))?;
let node_id: NodeId = node_id_str
.parse()
.map_err(|e| anyhow!("invalid iroh peer endpoint ID: {e}"))?;
let relay_url: Option<RelayUrl> = match iroh_relay.as_deref() {
Some(u) => Some(
u.parse()
.map_err(|e| anyhow!("invalid iroh relay URL: {e}"))?,
),
None => None,
};
let proxy_url: Option<url::Url> = match proxy.as_deref() {
Some(u) => Some(u.parse().map_err(|e| anyhow!("invalid proxy URL: {e}"))?),
None => None,
};
let t = Arc::new(
IrohTransport::new(node_id, relay_url, proxy_url)
.await
.map_err(|e| anyhow!("failed to create iroh transport: {e}"))?,
);
connect_and_run(opts, t).await
}
}
}
}
async fn connect_and_run<T: Transport>(opts: ConnectOptions, transport: Arc<T>) -> Result<()> {
alknet_core::client::ClientSession::new(opts, transport)
.await
.map_err(|e| anyhow!("{e}"))?
.run()
.await
.map_err(|e| anyhow!("{e}"))
}
#[allow(clippy::too_many_arguments)]
async fn run_serve(
key: String,
authorized_keys: Option<String>,
cert_authority: Option<String>,
transport: ServeTransportModeArg,
listen: String,
tls_cert: Option<String>,
tls_key: Option<String>,
acme_domain: Option<String>,
stealth: bool,
proxy: Option<String>,
iroh_relay: Option<String>,
max_connections_per_ip: usize,
max_auth_attempts: usize,
) -> Result<()> {
let transport_mode: ServeTransportMode = transport.into();
if acme_domain.is_some() {
#[cfg(not(feature = "acme"))]
{
return Err(anyhow!(
"ACME support is not available (alknet built without 'acme' feature)"
));
}
}
if stealth && transport_mode != ServeTransportMode::Tls {
return Err(anyhow!(
"stealth mode requires TLS transport (--transport tls)"
));
}
let mut opts = ServeOptions::new(KeySource::File(key.into()))
.transport_mode(transport_mode.clone())
.listen_addr(&listen)
.stealth(stealth)
.max_connections_per_ip(max_connections_per_ip)
.max_auth_attempts(max_auth_attempts);
if let Some(ref path) = authorized_keys {
opts = opts.authorized_keys(KeySource::File(path.into()));
}
if let Some(ref path) = cert_authority {
opts = opts.cert_authority(KeySource::File(path.into()));
}
if let Some(ref path) = tls_cert {
opts = opts.tls_cert(path);
}
if let Some(ref path) = tls_key {
opts = opts.tls_key(path);
}
if let Some(ref domain) = acme_domain {
opts = opts.acme_domain(domain);
}
if let Some(ref url) = proxy {
opts = opts.proxy(url);
}
if let Some(ref url) = iroh_relay {
opts = opts.iroh_relay(url);
}
opts.validate().map_err(|e| anyhow!("{e}"))?;
let server = Server::new(opts).map_err(|e| anyhow!("{e}"))?;
match transport_mode {
ServeTransportMode::Tcp => {
let addr: SocketAddr = listen
.parse()
.map_err(|e| anyhow!("invalid listen address: {e}"))?;
let acceptor = alknet_core::transport::TcpAcceptor::bind(addr)
.await
.map_err(|e| anyhow!("bind failed: {e}"))?;
server.run(acceptor, None).await.map_err(|e| anyhow!("{e}"))
}
ServeTransportMode::Tls => {
#[cfg(not(feature = "tls"))]
{
Err(anyhow!(
"TLS transport is not available (alknet-core built without 'tls' feature)"
))
}
#[cfg(feature = "acme")]
{
if let Some(ref domain) = acme_domain {
let addr: SocketAddr = listen
.parse()
.map_err(|e| anyhow!("invalid listen address: {e}"))?;
let provider = Arc::new(
alknet_core::transport::AcmeCertProvider::domain(domain)
.with_production_directory(),
);
let acceptor =
alknet_core::transport::AcmeTlsAcceptor::bind_acme(addr, provider)
.await
.map_err(|e| anyhow!("ACME bind failed: {e}"))?;
return server.run(acceptor, None).await.map_err(|e| anyhow!("{e}"));
}
}
#[cfg(feature = "tls")]
{
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
let addr: SocketAddr = listen
.parse()
.map_err(|e| anyhow!("invalid listen address: {e}"))?;
let cert_path = tls_cert.ok_or_else(|| {
anyhow!("--tls-cert is required for TLS transport (or use --acme-domain)")
})?;
let key_path = tls_key.ok_or_else(|| {
anyhow!("--tls-key is required for TLS transport (or use --acme-domain)")
})?;
let cert_data = std::fs::read(&cert_path)
.map_err(|e| anyhow!("failed to read TLS cert '{}': {e}", cert_path))?;
let key_data = std::fs::read(&key_path)
.map_err(|e| anyhow!("failed to read TLS key '{}': {e}", key_path))?;
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut &cert_data[..])
.collect::<Result<Vec<_>, _>>()
.map_err(|e| anyhow!("failed to parse TLS certificates: {e}"))?;
let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_data[..])
.map_err(|e| anyhow!("failed to parse TLS private key: {e}"))?
.ok_or_else(|| anyhow!("no private key found in {}", key_path))?;
let acceptor = alknet_core::transport::TlsAcceptor::bind(addr, certs, key, None)
.await
.map_err(|e| anyhow!("TLS bind failed: {e}"))?;
server.run(acceptor, None).await.map_err(|e| anyhow!("{e}"))
}
}
ServeTransportMode::Iroh => {
#[cfg(not(feature = "iroh"))]
{
Err(anyhow!(
"iroh transport is not available (alknet-core built without 'iroh' feature)"
))
}
#[cfg(feature = "iroh")]
{
use iroh::RelayUrl;
let relay_url: Option<RelayUrl> = match iroh_relay.as_deref() {
Some(u) => Some(
u.parse()
.map_err(|e| anyhow!("invalid iroh relay URL: {e}"))?,
),
None => None,
};
let proxy_url: Option<url::Url> = match proxy.as_deref() {
Some(u) => Some(u.parse().map_err(|e| anyhow!("invalid proxy URL: {e}"))?),
None => None,
};
let acceptor = alknet_core::transport::IrohAcceptor::bind(relay_url, proxy_url)
.await
.map_err(|e| anyhow!("iroh bind failed: {e}"))?;
let endpoint_id = acceptor.endpoint_id();
eprintln!("iroh endpoint ID: {endpoint_id}");
server
.run(acceptor, Some(&endpoint_id))
.await
.map_err(|e| anyhow!("{e}"))
}
}
}
}

View File

@@ -1,122 +1,113 @@
--- ---
status: draft status: draft
last_updated: 2026-06-09 last_updated: 2026-06-23
--- ---
# Alknet Architecture # Alknet Architecture
## Current State ## Current State
Architecture spec sync in progress. Phase 0 foundation complete (ADRs 001037). **Pre-implementation.** The project has completed a pivot from a three-layer model to an ALPN-as-service model. The greenfield workspace contains only `alknet-vault` (stable — implementation exists, pending ADR-025/026 refactor to drop irpc and remove derive_password) and research/reference material. Foundational ADRs (001026) are in place. ADR-024 resolves the registry mutability question and the `OperationContext.env` type identity crisis by layering the registry by trust boundary. ADR-025 drops irpc from the vault, making it local-only by construction. ADR-026 records the HD-derivation key model as a foundational decision. Review #003 (type/API surface completeness) resolved: `DerivedKey` derive contradiction, `encrypt` prose, return-type divergence, RwLock contradiction, drift table gaps, ADR-022 stale sketches, `Capabilities`/`SessionOverlaySource`/`CallConnection`/`CachedKey` definitions, `CompositeOperationEnv` dispatch contract, `with_local` signature, payload schemas, timeout propagation, and request ID generation. The alknet-core, alknet-call, and alknet-vault crate specs are in draft.
Phase 1 core modifications partially implemented (interface trait, config split,
identity provider, forwarding policy). Phase 2 core bridge research complete;
spec documents updated to reflect StreamInterface/MessageInterface split,
CredentialProvider as core type, and API keys in DynamicConfig.
Remaining open questions: OQ-15 (QUIC coexistence), OQ-19 (WebTransport TLS), **Next step**: Implementation. All open questions are resolved. The specs have passed three review passes (#001 governance/security model, #002 cross-document consistency/two-way-door audit, #003 type/API surface completeness).
OQ-20 (worker registration), OQ-CP-01 (per-identity credentials), OQ-CP-02
(OIDC provider location), OQ-CP-03 (credential rotation). See
[open-questions.md](open-questions.md).
## Architecture Documents ## Architecture Documents
| Document | Status | Description | | Document | Status | Description |
|----------|--------|-------------| |----------|--------|-------------|
| [overview.md](overview.md) | reviewed | Package purpose, crate structure, three-layer model, exports, dependencies | | [overview.md](overview.md) | draft | Workspace-level overview, crate graph, shared types, design principles |
| [transport.md](transport.md) | reviewed | Transport abstraction: TCP, TLS, iroh | | [open-questions.md](open-questions.md) | draft | Centralized OQ tracker with door-type classifications |
| [auth.md](auth.md) | draft | Unified auth: SSH + token + API keys, credential presentation per interface | | [crates/core/README.md](crates/core/README.md) | draft | alknet-core crate index |
| [call-protocol.md](call-protocol.md) | draft | Bidirectional call/event protocol, OperationEnv, three dispatch paths | | [crates/core/core-types.md](crates/core/core-types.md) | draft | ProtocolHandler, HandlerError, Connection, BiStream, StreamError |
| [client.md](client.md) | reviewed | Client connection, SOCKS5, port forwarding | | [crates/core/endpoint.md](crates/core/endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, shutdown |
| [server.md](server.md) | reviewed | Server acceptance, IdentityProvider, ForwardingPolicy, channel handling | | [crates/core/auth.md](crates/core/auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow |
| [tun-shim.md](tun-shim.md) | deprecated | TUN interface wrapper — **deferred**, use tun2proxy | | [crates/core/config.md](crates/core/config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle |
| [napi-and-pubsub.md](napi-and-pubsub.md) | reviewed | NAPI wrapper, reload API, pubsub event target adapter | | [crates/call/README.md](crates/call/README.md) | draft | alknet-call crate index |
| [identity.md](identity.md) | draft | Identity type, IdentityProvider trait, auth flows | | [crates/call/call-protocol.md](crates/call/call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls, streaming subscribe example |
| [services.md](services.md) | draft | irpc service layer, OperationEnv, three dispatch paths | | [crates/call/operation-registry.md](crates/call/operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, capability injection, service discovery, irpc integration |
| [interface.md](interface.md) | draft | StreamInterface, MessageInterface, credential presentation, ListenerConfig | | [crates/vault/README.md](crates/vault/README.md) | draft | alknet-vault crate index |
| [configuration.md](configuration.md) | draft | StaticConfig, DynamicConfig, API keys, forwarding policy, reload | | [crates/vault/mnemonic-derivation.md](crates/vault/mnemonic-derivation.md) | draft | BIP39, SLIP-0010, BIP-0032, derivation paths, key types |
| [storage.md](storage.md) | draft | alknet-storage: metagraph, identity, ACL, honker | | [crates/vault/encryption.md](crates/vault/encryption.md) | draft | AES-256-GCM, EncryptedData, key versioning, salt (Phase B reserved) |
| [flowgraph.md](flowgraph.md) | draft | alknet-flowgraph: call graph, operation graph, petgraph | | [crates/vault/service.md](crates/vault/service.md) | draft | VaultServiceHandle lifecycle, actor dispatch, cache, error model |
| [secret-service.md](secret-service.md) | reviewed | alknet-secret: BIP39, SLIP-0010, AES-GCM, SecretProtocol | | [crates/vault/protocol.md](crates/vault/protocol.md) | draft | DerivedKey redaction, KeyType, serialization behavior |
| [credentials.md](credentials.md) | draft | CredentialProvider, CredentialSet (outbound auth) |
| [definitions.md](definitions.md) | draft | Terminology disambiguation and concept mapping |
## Research Documents
| Document | Status | Description |
|----------|--------|-------------|
| [configuration.md](../research/configuration.md) | draft | Configuration architecture (source for promoted spec) |
| [core.md](../research/core.md) | draft | Core overview, transport, call protocol, DNS |
| [services.md](../research/services.md) | draft | irpc service protocols, OperationContext, application services |
| [storage.md](../research/storage.md) | draft | Metagraph, identity, ACL, secrets, honker |
| [flow.md](../research/flow.md) | draft | FlowGraph, operation graph, call graph, petgraph mapping |
| [integration-plan.md](../research/integration-plan.md) | draft | Phased integration plan for services, pubsub, and operations |
| [feasibility/](../research/feasibility/) | — | SSH tunnel feasibility assessment and related analyses |
| [event-sourcing/](../research/event-sourcing/) | — | Event sourcing patterns and event-driven architecture reference |
| [ops/](../research/ops/) | — | Production ops reference: certbot, fail2ban |
| [phase2/definitions.md](../research/phase2/definitions.md) | draft | Terminology disambiguation (promoted to architecture/definitions.md) |
| [phase2/interface-model.md](../research/phase2/interface-model.md) | draft | StreamInterface/MessageInterface analysis (promoted to interface.md) |
| [phase2/credential-provider.md](../research/phase2/credential-provider.md) | draft | CredentialProvider research (promoted to credentials.md) |
| [phase2/tls-transport.md](../research/phase2/tls-transport.md) | draft | HTTP interface, stealth handoff, ListenerConfig (promoted to interface.md, auth.md) |
## ADR Table ## ADR Table
| ADR | Title | Status | | ADR | Title | Status |
|-----|-------|--------| |-----|-------|--------|
| [001](decisions/001-pluggable-transport.md) | Pluggable transport via `AsyncRead+AsyncWrite` trait | Accepted | | [001](decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | Accepted |
| [002](decisions/002-tun-separate-process.md) | TUN shim as separate process | Superseded by ADR-014 | | [002](decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | Accepted |
| [003](decisions/003-iroh-stream-join.md) | iroh stream via `tokio::io::join` | Accepted | | [003](decisions/003-crate-decomposition.md) | Crate Decomposition | Accepted |
| [004](decisions/004-ssh-over-transport.md) | SSH runs over transport, not alongside | Accepted | | [004](decisions/004-auth-as-shared-core.md) | Auth as Shared Core (IdentityProvider) | Accepted |
| [005](decisions/005-socks5-before-tun.md) | SOCKS5 as primary interface, TUN as add-on | Accepted | | [005](decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | Accepted |
| [006](decisions/006-no-logging-of-tunnel-destinations.md) | No logging of tunnel destinations | Accepted | | [006](decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention and Connection Model | Accepted |
| [007](decisions/007-napi-single-stream.md) | NAPI exposes single duplex stream | Accepted | | [007](decisions/007-bistream-type-definition.md) | BiStream Type Definition | Accepted |
| [008](decisions/008-acme-lets-encrypt.md) | ACME/Let's Encrypt certificate provisioning | Accepted | | [008](decisions/008-secret-service-integration.md) | Vault Integration Point | Accepted |
| [009](decisions/009-default-iroh-relay.md) | Default iroh relay with override | Accepted | | [009](decisions/009-one-way-door-decision-framework.md) | One-Way Door Decision Framework | Accepted |
| [010](decisions/010-transport-chaining-cli.md) | Transport chaining in CLI | Accepted | | [010](decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Accepted |
| [011](decisions/011-no-ssh-config-programmatic-api.md) | Programmatic-first API, no file-based config | Accepted | | [011](decisions/011-authcontext-structure.md) | AuthContext Structure and Resolution Flow | Accepted |
| [012](decisions/012-auth-ed25519-and-cert-authority.md) | Ed25519 keys + OpenSSH cert-authority, no password auth | Accepted | | [012](decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Accepted |
| [013](decisions/013-fail2ban-friendly-logging.md) | Fail2ban-friendly logging + built-in rate limiting | Accepted | | [013](decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Accepted |
| [014](decisions/014-defer-tun-recommend-socks5-proxy.md) | Defer TUN, recommend local SOCKS5 + tun2proxy | Accepted | | [014](decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Accepted |
| [015](decisions/015-napi-rs-for-ffi-bridge.md) | napi-rs for FFI bridge | Accepted | | [015](decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Accepted |
| [016](decisions/016-napi-expose-connect-and-serve.md) | NAPI exposes both connect() and serve() | Accepted | | [016](decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | Accepted |
| [017](decisions/017-stealth-mode-protocol-multiplexing.md) | Stealth mode — protocol multiplexing on port 443 | Accepted | | [017](decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | Accepted |
| [018](decisions/018-control-channel-for-pubsub.md) | Control channel for pubsub over SSH | Accepted | | [018](decisions/018-vault-standalone-crate.md) | Vault as Standalone Crate | Accepted |
| [019](decisions/019-proxy-dual-semantics.md) | `--proxy` dual semantics (client vs server) | Accepted | | [019](decisions/019-vault-assembly-layer-only.md) | Vault Assembly-Layer-Only Access | Accepted |
| [023](decisions/023-unified-auth-shared-key-material.md) | Unified auth with shared key material + token auth | Accepted | | [020](decisions/020-hd-derivation-for-encryption-keys.md) | HD Derivation for Encryption Keys | Accepted |
| [024](decisions/024-bidirectional-call-protocol.md) | Bidirectional call protocol (EventEnvelope) | Accepted | | [021](decisions/021-key-rotation-via-version-indexed-paths.md) | Key Rotation via Version-Indexed Paths | Accepted |
| [025](decisions/025-handler-spec-separation.md) | Handler/spec separation for downstream service registration | Accepted | | [022](decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Accepted |
| [026](decisions/026-transport-interface-separation.md) | Transport/interface separation (three-layer model) | Accepted | | [023](decisions/023-operation-error-schemas.md) | Operation Error Schemas | Accepted |
| [027](decisions/027-crate-decomposition.md) | Crate decomposition (core, secret, storage, flowgraph) | Accepted | | [024](decisions/024-operation-registry-layering.md) | Operation Registry Layering | Accepted |
| [028](decisions/028-auth-irpc-service.md) | Auth as irpc service behind feature flag | Accepted | | [025](decisions/025-vault-local-only-dispatch.md) | Vault Local-Only Dispatch | Accepted |
| [029](decisions/029-identity-core-type.md) | Identity as core type in alknet-core | Accepted | | [026](decisions/026-vault-key-model-hd-derivation.md) | Vault Key Model — HD Derivation | Accepted |
| [030](decisions/030-static-dynamic-config-split.md) | Static/dynamic config split with ArcSwap | Accepted |
| [031](decisions/031-forwarding-policy.md) | Forwarding policy with rule-based allow/deny | Accepted |
| [032](decisions/032-event-boundary-discipline.md) | Event boundary discipline (domain, irpc, call protocol) | Accepted |
| [033](decisions/033-operationenv-irpc-call-protocol.md) | OperationEnv as universal composition mechanism | Accepted |
| [034](decisions/034-head-worker-terminology.md) | Head/worker terminology replacing hub/spoke | Accepted |
| [035](decisions/035-streaminterface-messageinterface-split.md) | StreamInterface / MessageInterface split | Accepted |
| [036](decisions/036-credentialprovider-core-type.md) | CredentialProvider as core type (outbound auth) | Accepted |
| [037](decisions/037-api-keys-dynamic-config.md) | API keys as DynamicConfig auth | Accepted |
| [038](decisions/038-seed-lifecycle-memory-security.md) | Seed lifecycle and memory security (zeroize for v1) | Accepted |
> ADR numbers 020022 were allocated to proposals that were withdrawn before
> acceptance and are not listed.
## Open Questions ## Open Questions
See [open-questions.md](open-questions.md) for all open and resolved questions. See [open-questions.md](open-questions.md) for the full tracker.
Key resolved questions from Phase 0: OQ-12, OQ-16, OQ-18 (forwarding policy
and identity scopes), OQ-17 (transport-aware auth), OQ-23 (irpc feature flag),
OQ-24 (DNS control channel scope), OQ-25 (crate irpc dependencies), OQ-IF-01
(Interface session / EventEnvelope relationship), OQ-IF-02 (ForwardingPolicy
placement). Key open questions: OQ-15 (QUIC coexistence), OQ-19 (WebTransport
TLS), OQ-20 (worker registration).
## Lifecycle Definitions **Resolved one-way doors:**
- **OQ-01**: BiStream type — trait with Connection parameter (ADR-007)
- **OQ-02**: AuthContext timing — hybrid model (ADR-004)
- **OQ-03**: ALPN naming — `alknet/` prefix, no version (ADR-006)
- **OQ-05**: Multi-connectivity endpoint — quinn + iroh, both feature-gated (ADR-010)
- **OQ-06**: ALPN per connection, not per stream (ADR-006)
- **OQ-08**: Vault integration — CLI-embedded, assembly-layer only (ADR-008, ADR-014)
- **OQ-16**: Safe vault operations for call protocol exposure — none for now (ADR-014)
- **OQ-18**: Privilege model — `internal` = authority switch, External/Internal visibility, handler identity + scoped env (ADR-015)
- **OQ-17**: Abort cascade — `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in (ADR-016)
- **OQ-15**: Call protocol client and adapter contract — `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction (ADR-017)
**Resolved two-way doors:**
- **OQ-04**: Dynamic handler registration — static at startup (ADR-010); scoped to the `HandlerRegistry` (ALPN-level) by ADR-024, which governs `OperationRegistry` mutability separately
- **OQ-07**: Call protocol scope — bidirectional streams, EventEnvelope, ID-based correlation (ADR-012)
- **OQ-11**: Handler-level auth resolution observability — handlers store resolved identity on Connection (Option B); two identity scopes: connection-level (observability) and per-request (ACL)
- **OQ-12**: TLS identity provisioning — two use cases: RFC 7250 raw keys (default, P2P) and X.509 certs (domain-hosted, browsers). ACME is a proven pattern.
- **OQ-13**: Operation path format — `/{service}/{op}` is the correct design for alknet-call, not a simplification
- **OQ-14**: Batch operation semantics — multiple correlated `call.requested` events is the correct protocol design, not a simplification
- **OQ-19**: Session-scoped registries — agent-written operations via `OperationEnv` trait layering; protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-024 to cover connection-scoped overlays as well.
- **OQ-20**: Encryption key derivation — HD derivation from BIP39 seed, not PBKDF2; salt field unused in v2 (wire-format compat) (ADR-020)
- **OQ-21**: Remote vault access — resolved (ADR-025): vault is local-only by construction; remote access requires a separate vault-server crate with its own ADR
- **OQ-22**: Key rotation — version-indexed derivation paths; `rotate` method re-encrypts (ADR-021)
- **OQ-23**: Handler identity registration path — registration bundle with provenance, composition authority, scoped env, capabilities (ADR-022)
- **OQ-24**: Operation error schemas — declared domain errors with typed `details` payload; adapter fidelity for `from_openapi`/`to_openapi` (ADR-023)
**Deferred (not active):**
- **OQ-09**: WASM target boundaries — design constraint, not deliverable
- **OQ-10**: Git adapter scope — start with smart protocol, add ERC721 later
## Document Lifecycle
| Status | Meaning | Transitions | | Status | Meaning | Transitions |
|--------|---------|-------------| |--------|---------|-------------|
| `draft` | Under active development. May change significantly. | → `reviewed` when open questions resolved | | `draft` | Under active development. May change significantly. | → `reviewed` when open questions are resolved |
| `reviewed` | Architecture final. Implementation may begin. Changes require review. | → `stable` when implementation is complete and verified | | `reviewed` | Architecture is final. Implementation may begin. Changes require review. | → `stable` when implementation is complete and verified |
| `stable` | Locked. Changes require review and may warrant an ADR. | → `deprecated` when superseded | | `stable` | Locked. Changes require review and may warrant an ADR. | → `deprecated` when superseded |
| `deprecated` | Superseded. Kept for reference. | Removed when no longer referenced | | `deprecated` | Superseded. Kept for reference. | Removed when no longer referenced |
## References
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`
- Cleanup plan: `docs/research/pivot/cleanup-plan.md`
- SDD process: `docs/sdd_process.md`
- Reference implementation: `/workspace/@alkdev/alknet-main/`

View File

@@ -1,339 +0,0 @@
---
status: draft
last_updated: 2026-06-09
---
# Authentication
## What
A unified authentication layer that works across all transports — SSH-over-any-
transport and WebTransport (non-SSH HTTP-level transports). The same key
material (Ed25519 authorized keys and certificate authorities) is shared across
both auth paths. Identity resolution produces a transport-agnostic `Identity`
that carries scopes and resources for downstream authorization.
## Why
Alknet currently authenticates connections exclusively through SSH public key
auth. Non-SSH transports (WebTransport) cannot perform SSH key exchange — they
need a different auth presentation that shares the same key material. The
unified auth layer ensures one key set, one identity, one rotation mechanism
across all transports. See ADR-023 for the decision context.
The canonical definitions of `Identity` and `IdentityProvider` are in
[identity.md](identity.md). This document covers auth-specific behavior:
auth presentation per transport, `AuthPolicy` structure, and the auth service
relationship.
## Architecture
### Identity and IdentityProvider
See [identity.md](identity.md) for the canonical definitions of:
- `Identity` struct (`{ id, scopes, resources }`)
- `IdentityProvider` trait (`resolve_from_fingerprint()`, `resolve_from_token()`)
- `ConfigIdentityProvider` (default, ArcSwap-backed)
- `StorageIdentityProvider` (production, SQLite-backed, in alknet-storage)
- `AuthProtocol` irpc service (behind `irpc` feature flag)
The key relationship: `IdentityProvider` is the contract. `ConfigIdentityProvider`
is the default implementation (reads from `DynamicConfig.auth`). `AuthProtocol`
irpc service is one way to satisfy the trait, behind a feature flag. Both paths
produce the same `Identity` result. See ADR-028 and ADR-029.
### Credential Presentation Per Interface
Each (Transport, Interface) pair presents credentials differently, but all
resolve to the same `Identity` through `IdentityProvider`. See
[definitions.md](definitions.md) for the full terminology rules.
| (Transport, Interface) | Credential presentation | Resolves via |
|------------------------|------------------------|-------------|
| (TLS, SshInterface) | SSH public key handshake | `resolve_from_fingerprint()` |
| (TCP, SshInterface) | SSH public key handshake | `resolve_from_fingerprint()` |
| (iroh, SshInterface) | SSH public key handshake | `resolve_from_fingerprint()` |
| (TLS, RawFramingInterface) | AuthToken in frame header | `resolve_from_token()` |
| (TCP, RawFramingInterface) | AuthToken in frame header | `resolve_from_token()` |
| (WebTransport, RawFramingInterface) | AuthToken in CONNECT request | `resolve_from_token()` |
| (—, HttpInterface) | `Authorization: Bearer` header | `resolve_from_token()` |
| (—, DnsInterface) | AuthToken in query labels | `resolve_from_token()` |
The **key material is shared**. The **credential presentation** differs per
(Transport, Interface) pair. The **verification result is the same**: an
authenticated `Identity` with scopes.
`resolve_from_token()` handles both AuthTokens (Ed25519-signed) and API keys
(hash-verified bearer tokens). The implementation discriminates by prefix or
format — see ADR-037.
### Token Authentication
For non-SSH transports, the client constructs an authentication token:
```
AuthToken = base64url(key_id || timestamp || signature)
key_id = SHA-256 fingerprint of the Ed25519 public key (32 bytes)
timestamp = Unix seconds, big-endian u64 (8 bytes)
signature = Ed25519 sign(key_id || timestamp_bytes, private_key)
```
Wire format when passed in a WebTransport CONNECT request:
```
CONNECT https://server:443/alknet?token=<AuthToken>
```
Server verification:
1. Base64url-decode the token
2. Extract `key_id` (first 32 bytes)
3. Look up `key_id` in the same `authorized_keys` set that SSH auth uses
4. Verify the Ed25519 `signature` against `(key_id || timestamp_bytes)` using
the matching public key
5. Check `timestamp` is within the acceptable window (configurable, default
±300 seconds)
6. Resolve to the same `Identity` that SSH pubkey auth would produce
The key fingerprint in the token serves double duty: it identifies which key
to verify against, and it ties the signature to a specific key (swapping
`key_id` invalidates the signature).
### Replay Protection
V1 uses timestamp-only (±300s window, no server state). The replay trade-offs
and future zero-replay options (nonce challenge-response) are documented in
ADR-023.
### IdentityProvider and Auth Service Relationship
The `IdentityProvider` trait (defined in [identity.md](identity.md)) decouples
alknet-core from any specific identity storage. Two implementations exist:
- **ConfigIdentityProvider** (in alknet-core) — reads from
`ArcSwap<DynamicConfig.auth>`. Every authorized key gets a default scope set.
No database required. This is the default for minimal deployments.
- **StorageIdentityProvider** (in alknet-storage) — backed by SQLite
`peer_credentials` and `api_keys` tables plus the ACL graph. Resolves
fingerprint → account → organization membership → effective scopes.
The `AuthProtocol` irpc service (behind the `irpc` feature flag, per ADR-028)
provides an async boundary for auth verification. It is one way to satisfy the
`IdentityProvider` trait, not a replacement for it. Both the trait path and the
irpc path produce the same `Identity` result.
The trait is the contract. The backing store is pluggable. Alknet-core never
depends on Honker, SQLite, or any specific database.
### API Keys
For service accounts, automation, and HTTP interface auth, Ed25519 AuthTokens
are inconvenient — they require client-side key generation and signing. API keys
provide a simpler bearer token format (ADR-037):
```
API key: "alk_dGhlX3NlY3JldA" (~20 chars, configurable prefix)
Storage: SHA-256 hash of the full key
Lookup: prefix match → hash verification → Identity
```
API keys are configured in `DynamicConfig.auth.api_keys`:
```toml
[[auth.api_keys]]
prefix = "alk_"
hash = "sha256:abc..."
scopes = ["relay:connect"]
description = "dashboard service account"
ttl = "30d" # optional
```
Both AuthTokens and API keys go through `IdentityProvider::resolve_from_token()`.
The implementation discriminates by prefix (default `alk_`): if the token starts
with the API key prefix, it's verified by SHA-256 hash lookup; otherwise, it's
verified as an Ed25519 AuthToken. Both paths produce the same `Identity`.
See [configuration.md](configuration.md) for the full `DynamicConfig.auth`
structure and ADR-037 for the decision context.
### AuthPolicy Structure
`AuthPolicy` in `DynamicConfig` holds all auth paths, sharing key material:
```rust
pub struct AuthPolicy {
pub ssh: SshAuthConfig,
pub token: TokenAuthConfig,
pub api_keys: Vec<ApiKeyEntry>,
}
pub struct SshAuthConfig {
pub authorized_keys: HashSet<PublicKey>,
pub cert_authorities: Vec<CertAuthorityEntry>,
// Existing fields from current ServerAuthConfig
}
pub struct TokenAuthConfig {
pub enabled: bool,
pub max_token_age: Duration, // Timestamp window (default: 300s)
pub key_source: TokenKeySource,
}
pub enum TokenKeySource {
/// Share the same authorized_keys set with SshAuthConfig.
/// Default and recommended for v1.
Shared,
/// Separate key set for non-SSH transports.
/// For deployments that want distinct access control per transport.
Separate(HashSet<PublicKey>),
}
pub struct ApiKeyEntry {
pub prefix: String, // e.g., "alk_"
pub hash: String, // e.g., "sha256:abc..."
pub scopes: Vec<String>, // e.g., ["relay:connect", "secrets:derive"]
pub description: Option<String>, // e.g., "dashboard service account"
pub expires_at: Option<u64>, // Unix timestamp, optional TTL
}
```
When `TokenKeySource::Shared` (the default), adding a key to
`authorized_keys` immediately grants access via both SSH and WebTransport.
One key set, one `reloadAuth()` call, one rotation.
### Auth Flow in the Server
**SSH transport (existing, unchanged):**
```
Client connects → SSH handshake → auth_publickey() callback
→ ServerAuthConfig::authenticate_publickey() or authenticate_certificate()
→ Auth::Accept or Auth::Reject
```
**WebTransport transport (new):**
```
Browser connects → WebTransport CONNECT request
→ SessionRequest inspection: extract token from URL path or header
→ TokenAuthConfig verification: decode token → lookup key_id → verify signature → check timestamp
→ session_request.accept() or session_request.forbidden()
```
After auth, both paths produce an `Identity`. The `Identity` is attached to the
connection and used by `ForwardingPolicy` and the call protocol to make
authorization decisions.
### WebTransport SessionRequest Inspection
The wtransport library's `SessionRequest` provides:
- `path()` — URL path (e.g., `/alknet?token=...`)
- `headers()` — HTTP headers (for `Authorization: Bearer ...`)
- `origin()` — Browser origin (for CORS-like restrictions)
- `remote_address()` — Client UDP address
Token extraction from URL path is preferred for browser WebTransport because
the W3C API (`new WebTransport(url)`) naturally includes query parameters. For
native clients (Deno, CLI), the `Authorization` header is also supported.
### Browser-Side Token Construction
```javascript
// Illustrative — see client SDK for production implementation
async function createAuthToken(keyPair) {
const publicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
const keyId = new Uint8Array(await crypto.subtle.digest('SHA-256', publicKey));
const timestamp = new ArrayBuffer(8);
new DataView(timestamp).setBigUint64(0, BigInt(Math.floor(Date.now() / 1000)));
const message = new Uint8Array([...keyId, ...new Uint8Array(timestamp)]);
const signature = await crypto.subtle.sign('Ed25519', keyPair.privateKey, message);
const token = new Uint8Array([...keyId, ...new Uint8Array(timestamp), ...new Uint8Array(signature)]);
return btoa(String.fromCharCode(...token))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
```
Browsers support Ed25519 key generation and signing via `SubtleCrypto` (Chrome
105+, Firefox 130+, Safari 17+). Deno supports it natively. No external
dependencies needed.
## Constraints
- Auth tokens are Ed25519-signed with the same key pair used for SSH auth. No
separate key management for non-SSH transports.
- `IdentityProvider` is the only interface between alknet-core and identity
storage. No database dependency at the core level.
- The SSH auth path is unchanged. `auth_publickey()` continues to work exactly
as it does today. Token auth is additive.
- Certificate authority tokens are not supported for token auth in v1. CA
verification requires the full OpenSSH certificate structure, which doesn't
fit in a simple signed timestamp. This can be added later if needed.
- Token auth is only available on transports that carry HTTP metadata (URL
path, headers). SSH-over-TCP/TLS/iroh continues to use SSH native auth
exclusively.
- API keys are bearer tokens — anyone who obtains the key has the associated
permissions. The hash storage and optional TTL mitigate but do not eliminate
this risk. Ed25519 AuthTokens remain the preferred auth method for interactive
clients. See ADR-037.
- API keys are verified by SHA-256 hash lookup in `DynamicConfig.auth.api_keys`
(or the `api_keys` database table in production). The full key is provided to
the client exactly once at creation time.
### Security Considerations
**Token in URL**: The auth token is passed as a URL query parameter
(`?token=...`) for browser WebTransport compatibility. This is a known web
security consideration:
- **Server logs**: The token may appear in HTTP access logs. Servers MUST
strip or redact the `token` query parameter before logging the request URL.
- **Browser history**: The token may appear in browser history. Timestamps
limit exposure to the token window (±300s).
- **Referrer headers**: WebTransport does not send referrer headers, so the
token does not leak via HTTP Referer.
- **Native clients**: Deno and native clients SHOULD prefer the `Authorization:
Bearer` header over URL parameters when the client supports custom headers.
## Open Questions
- **OQ-18**: ~~Source of Identity.scopes~~ Resolved per ADR-029 and ADR-031.
`IdentityProvider` owns scopes, `ForwardingPolicy` uses scopes from `Identity`.
See [open-questions.md](open-questions.md).
- **OQ-19**: Should the WebTransport listener require its own TLS identity
(separate from the SSH-over-TLS listener), or can they share the same
certificate? Deferred to Phase 4. See [open-questions.md](open-questions.md).
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [012](decisions/012-auth-ed25519-and-cert-authority.md) | Ed25519 + cert-authority | Key-based auth, no passwords |
| [023](decisions/023-unified-auth-shared-key-material.md) | Unified auth, shared key material | Same keys for SSH and token auth |
| [028](decisions/028-auth-irpc-service.md) | Auth as irpc service | AuthProtocol behind feature flag; IdentityProvider is the contract |
| [029](decisions/029-identity-core-type.md) | Identity as core type | `Identity` and `IdentityProvider` in alknet-core |
| [035](decisions/035-streaminterface-messageinterface-split.md) | StreamInterface/MessageInterface | Credential presentation differs per (Transport, Interface) pair |
| [037](decisions/037-api-keys-dynamic-config.md) | API keys in DynamicConfig | Hash-verified bearer tokens for service accounts |
## Phase 2 Implementation Notes
- `ConfigIdentityProvider::resolve_from_token()` now handles API keys (`alk_` prefix) via SHA-256 hash verification with expiry checking
- `ApiKeyEntry` struct added to `AuthPolicy` with prefix, hash, scopes, description, expires_at fields
- API keys produce `Identity { id: prefix, scopes: from_entry, resources: {} }`
- Both AuthTokens (Ed25519 signed) and API keys (hash-verified bearer) go through `resolve_from_token()`, discriminated by format/prefix
## References
- [identity.md](identity.md) — Canonical Identity and IdentityProvider definitions
- [server.md](server.md) — Current SSH auth handler
- [transport.md](transport.md) — Transport abstraction
- [configuration.md](configuration.md) — DynamicConfig, AuthPolicy, ConfigReloadHandle
- [interface.md](interface.md) — Credential presentation per (Transport, Interface) pair
- [definitions.md](definitions.md) — Terminology disambiguation (IdentityProvider vs CredentialProvider, AuthToken vs API key)
- [services.md](services.md) — AuthProtocol irpc service
- [open-questions.md](open-questions.md) — OQ-17 (resolved), OQ-18 (resolved), OQ-19
- [wtransport](https://github.com/BiagioFesta/wtransport) — Rust WebTransport library
- [WebTransport W3C Spec](https://www.w3.org/TR/webtransport/) — Browser API

View File

@@ -1,551 +0,0 @@
---
status: draft
last_updated: 2026-06-09
---
# Call Protocol
## What
A bidirectional, transport-agnostic call and event protocol that runs over
authenticated pipes. It supports request/response calls, streaming
subscriptions, and unidirectional events — all using the same wire format. The
protocol is defined as a spec + handler + registry; downstream consumers (NAPI,
Python, head/worker) register their own operations without modifying core.
OperationEnv extends the call protocol with a universal composition mechanism
that unifies local dispatch, irpc service dispatch, and remote dispatch. A
handler receives `context.env.invoke(namespace, op, input)` and doesn't know
whether the operation runs locally, in-cluster, or on a remote node.
## Why
The current control channel (ADR-018) is unidirectional (client → server) and
provides fire-and-forget event dispatch without request/response semantics.
The call protocol generalizes it to support bidirectional calls (ADR-024) and
downstream service registration (ADR-025), enabling the head/worker model where
workers expose operations the head invokes.
Without OperationEnv, handlers calling other operations would need to know
whether the target is local, in-cluster, or on a remote node. OperationEnv
abstracts this away — one handler-facing API, three dispatch backends (ADR-033).
## Architecture
### Operation Paths
Operation names use slash-based paths aligned with URL routing conventions:
```
/{node}/{service}/{op}
```
- **node** — identity prefix of the node that exposes the operation. The head
uses this segment to route calls to the correct connected node.
- **service** — the logical service namespace. Groups related operations
under one handler prefix.
- **op** — the specific operation within that service.
Examples:
| Path | Meaning |
|------|---------|
| `/dev1/fs/readFile` | Node `dev1`, service `fs`, operation `readFile` |
| `/dev1/bash/exec` | Node `dev1`, service `bash`, operation `exec` |
| `/head/agent/chat` | Head's own `agent` service, operation `chat` |
| `/head/sessions/list` | Head's own `sessions` service, operation `list` |
| `/browser-1/notify/alert` | Worker `browser-1`, `notify` service |
This three-level routing mirrors iroh's ALPN dispatch: the first segment
routes to a connected node (like ALPN routes to a protocol handler), the
remaining path dispatches within that node's registry. See ADR-025 for the
handler/spec separation decision.
The `namespace` field on `OperationSpec` is derived from the path (`namespace`
= second path segment). It's a convenience accessor for ACL matching and
service grouping.
### Wire Format: EventEnvelope
Every message on the wire is a length-prefixed JSON `EventEnvelope`:
```rust
pub struct EventEnvelope {
pub r#type: String, // Event type (e.g., "call.requested", "call.responded")
pub id: String, // Correlation key (requestId, topic, or "" for broadcasts)
pub payload: Value, // JSON payload — schema depends on event type
}
// Frame: 4-byte big-endian length prefix + UTF-8 JSON body
```
This is the same format used by `@alkdev/pubsub` adapters. It is JSON because
it must be consumable from JavaScript, Python, and any language. The envelope
is transport-agnostic — it runs over SSH channels, WebTransport streams, iroh
bidirectional streams, WebSocket, or Worker postMessage.
Binary payloads (postcard, protobuf, etc.) are base64-encoded in the `payload`
field. The envelope itself stays JSON for cross-language compatibility.
### Call Protocol Events
Five event types carry request/response and subscription semantics:
| Event | Direction | Purpose |
|-------|-----------|---------|
| `call.requested` | Caller → Handler | Initiate a call or subscription |
| `call.responded` | Handler → Caller | Deliver a result (one for calls, many for subscriptions) |
| `call.completed` | Handler → Caller | Signal end of subscription stream |
| `call.aborted` | Either side | Cancel the call/subscription |
| `call.error` | Handler → Caller | Signal an error |
**`call.error` payload**:
```json
{
"code": "string",
"message": "string",
"retryable": false
}
```
**A call is just a subscribe that resolves after one event.** Both `call()` and
`subscribe()` send the same `call.requested` event. The difference is
consumption pattern:
- **`call()`**: Sends `call.requested`, resolves `Promise` on first `call.responded`
- **`subscribe()`**: Sends `call.requested`, yields each `call.responded` until `call.completed` or `call.aborted`
The `id` field carries the `requestId` for correlation.
### Bidirectional Calls and Routing
Both sides of a connection can initiate calls. The head routes calls to workers
using the first path segment:
```
Head (server) Worker: "dev1" (client)
│ │
│ call.requested │
│ name: "/dev1/fs/readFile" │
│ payload: { path: "/src/main.rs" } │
│──────────────────────────────────────────▶│
│ │
│ call.responded │
│ id: <requestId> │
│ payload: { content: "fn main()..." } │
│◀──────────────────────────────────────────│
│ │
│ Worker exposes /dev1/fs/*, │
│ /dev1/bash/* to head │
│ │
│◀─ call.requested ────────────────────────│
│ name: "/head/agent/chat" │
│ payload: { provider: "anthropic", ... } │
│ │
│── call.responded ──────────────────────▶ │
│ id: <requestId> │
│ payload: { completion: "..." } │
```
The head's registry includes:
- **Head-local operations** (`/head/*`) — handled directly
- **Remote operations** (`/{node}/*`) — forwarded to the worker connection
When the head routes `/dev1/fs/readFile` to worker `dev1`, it strips the node
prefix and delivers the call to the worker's local registry as `/fs/readFile`.
The worker doesn't need to know its own alias.
### Head/Worker Architecture
```
┌─────────────────────────────────┐
│ Head Node │
│ │
│ Head-local services: │
│ /head/agent/chat (LLM coord) │
│ /head/agent/complete │
│ /head/sessions/list │
│ /head/sessions/history │
│ │
│ Worker registry (discovered): │
│ /dev1/fs/* → dev1 connection │
│ /dev1/bash/* → dev1 connection │
│ /dev2/fs/* → dev2 connection │
│ /browser-1/notify/* → WT conn │
└──────┬───────┬───────┬──────────┘
│ │ │
┌─────────▼┐ ┌───▼────┐ ┌▼───────────┐
│ Worker │ │Worker │ │Browser Worker│
│ "dev1" │ │"dev2" │ │"browser-1" │
│ /fs/* │ │/fs/* │ │/notify/* │
│ /bash/* │ │/bash/* │ │ │
│ /search/*│ │ │ │ │
└──────────┘ └────────┘ └─────────────┘
```
When a worker connects, it registers its operations with the head:
```
worker → head: call.requested { name: "/head/services/register", payload: {
node: "dev1",
operations: ["/fs/readFile", "/fs/writeFile", "/bash/exec", "/search/query"]
}}
```
The head adds these to its routing table with the node prefix. Other workers
and browser clients can then call `/dev1/fs/readFile` without knowing how
the head routes it internally.
### Operation Registry
The operation registry maps paths to specs and handlers. **Specs and handlers
are separate** — downstream consumers register both (ADR-025).
```rust
pub struct OperationSpec {
pub name: String, // e.g., "/fs/readFile", "/agent/chat"
pub namespace: String, // e.g., "fs", "agent"
pub op_type: OperationType, // Query, Mutation, Subscription
pub input_schema: Value, // JSON Schema for input
pub output_schema: Value, // JSON Schema for output
pub access_control: AccessControl, // Required scopes/resources
}
pub enum OperationType {
Query, // Read-only, idempotent (e.g., "/fs/readFile", "/search/query")
Mutation, // Side effects (e.g., "/bash/exec", "/sessions/create")
Subscription, // Streaming (e.g., "/events/subscribe")
}
pub struct AccessControl {
pub required_scopes: Vec<String>, // AND-checked
pub required_scopes_any: Option<Vec<String>>, // OR-checked
pub resource_type: Option<String>, // e.g., "service"
pub resource_action: Option<String>, // e.g., "read"
}
```
**Registration is separated from implementation:**
```rust
// Core registers discovery operations
registry.register(OperationSpec { name: "/services/list", ... }, list_services_handler);
registry.register(OperationSpec { name: "/services/schema", ... }, schema_handler);
// A dev env worker registers its tools
registry.register(OperationSpec { name: "/fs/readFile", ... }, fs_read_handler);
registry.register(OperationSpec { name: "/bash/exec", ... }, bash_exec_handler);
// A browser client registers notification UDFs
registry.register(OperationSpec { name: "/notify/alert", ... }, notify_handler);
```
Core-provided operations use short paths without a node prefix
(`/services/list`, `/services/schema`). They live on whatever node the
caller is connected to. Worker-prefixed operations (`/dev1/fs/readFile`)
are routed by the head.
### ACL Per Operation Path
Access control maps to path prefixes using standard URL-like matching:
| Pattern | Matches | Purpose |
|---------|---------|---------|
| `/dev1/*` | All operations on node `dev1` | Full access to a worker |
| `/*/fs/*` | `fs` service on any node | Read file access across dev envs |
| `/*/bash/*` | `bash` service on any node | Shell access (higher risk) |
| `/head/agent/*` | Head LLM agent | LLM calls |
| `/head/sessions/*` | Head session management | Session history |
| `/browser-1/notify/alert` | Specific operation on specific node | One UI notification |
Higher-risk operations (shell, filesystem write) can require tighter scopes
than read-only operations. The ACL evaluates against the caller's
`Identity.scopes` and `Identity.resources` from the auth layer (see auth.md).
### Service Discovery
The `/services/list` and `/services/schema` operations expose what a node
offers. Read-only — no admin operations:
| Operation | Type | Description |
|-----------|------|-------------|
| `/services/list` | Query | List registered operation paths + metadata |
| `/services/schema` | Query | Get `OperationSpec` for a specific operation |
These tell the caller: "here's what you can call." They are not a control
panel. Access control is enforced at the operation level.
### PendingRequestMap
Manages in-flight calls and subscriptions. Correlates `call.responded` events
back to the original `call.requested`:
```rust
pub struct PendingRequestMap {
pending: HashMap<String, PendingEntry>,
}
enum PendingEntry {
Call {
tx: oneshot::Sender<Result<Value>>,
timeout: Instant,
},
Subscribe {
tx: mpsc::Sender<Result<Value>>,
timeout: Option<Instant>,
},
}
```
When a `call.responded` event arrives:
- If `PendingEntry::Call` → resolve the oneshot, delete entry
- If `PendingEntry::Subscribe` → push to the mpsc channel, keep entry alive
When `call.completed` arrives on a subscription → close the mpsc channel, delete
entry. When `call.aborted` arrives → cancel/drop whichever side initiated it. A
`call.aborted` for an unknown `requestId` is silently discarded — no error
response is generated.
Timeouts prevent dangling entries. A background task sweeps expired entries
periodically.
### Protocol Adapter Layer
The call protocol is transport-agnostic and interface-agnostic by design. It
receives input from two interface categories (ADR-035):
**StreamInterface** produces `InterfaceEvent` frames from a continuous byte
stream (SSH channel, raw framing). The call protocol handler calls `recv()`
on the session to get events.
**MessageInterface** handles individual `InterfaceRequest``InterfaceResponse`
pairs (HTTP, DNS). The call protocol handler constructs an `OperationContext`
from the request and invokes the registry directly.
Both paths resolve to the same `OperationRegistry` and `OperationEnv`:
| Transport | Channel mechanism | Direction |
|-----------|-------------------|-----------|
| SSH | Reserved `direct_tcpip` destination (ADR-018) | Bidirectional over SSH channel |
| WebTransport | Bidirectional stream after CONNECT | Bidirectional over WT stream |
| iroh QUIC | Bidirectional `open_bi()` / `accept_bi()` | Bidirectional over QUIC stream |
| WebSocket | Single WS connection | Bidirectional over WS frames |
| Worker | `postMessage` | Bidirectional over structured clone |
The framing is always: 4-byte BE length prefix + JSON. The envelope shape is
the same regardless of transport.
### OperationEnv — Universal Composition Mechanism
OperationEnv provides the handler-facing API for composing operations. A handler
receives `context.env.invoke(namespace, operation, input)` and gets back a
`ResponseEnvelope` — regardless of which dispatch path the operation takes
(ADR-033).
Three dispatch paths, one API:
| Path | Mechanism | Serialization | Scope |
|------|-----------|---------------|-------|
| **Local** | Direct function call through registry | None (in-process) | Same process |
| **Service** | irpc protocol enum dispatch | postcard (binary) | Same cluster |
| **Remote** | Call protocol `EventEnvelope` | JSON | Cross-node |
All three produce the same `ResponseEnvelope`. Service assembly determines
which path each operation uses:
```rust
// Minimal deployment (Phase 1: single node, all local)
let env = OperationEnv::local(local_registry);
// Production deployment (Phase 2+: mix of local and remote)
let env = OperationEnv::new()
.local("auth", auth_registry)
.local("config", config_registry)
.service("secrets", secret_irpc_client)
.remote("worker-1", call_protocol_conn);
```
**Phase boundary**: Phase 1 ships with local dispatch only (direct function
calls through the operation registry). The irpc service dispatch and remote
dispatch paths are contracted here but not built yet. irpc service protocols
(`AuthProtocol`, `SecretProtocol`, etc.) are defined in the specs but the
implementations are Phase 2+ work.
**irpc is one dispatch backend for OperationEnv, not a replacement for the
call protocol or for OperationEnv.** A call protocol handler can call an irpc
service internally (e.g., `/head/auth/verify` calls
`AuthProtocol::VerifyPubkey`) — the layers compose. irpc is behind a feature
flag in alknet-core. See [services.md](services.md) for full OperationEnv and
irpc service details.
### OperationContext
Every handler receives an `OperationContext`:
```rust
pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<Identity>,
pub metadata: HashMap<String, Value>,
pub env: OperationEnv,
pub trusted: bool, // set by buildEnv(), not by callers
}
```
- **`identity`**: The authenticated identity making the call. Populated by
`IdentityProvider` from the interface layer ([identity.md](identity.md)).
- **`env`**: The operation environment — namespaced access to other operations.
- **`trusted`**: When a handler calls another operation through `env`, the
nested call is `trusted` (skips ACL checks). This prevents double-checking:
if `/head/agent/chat` is allowed, and it internally calls
`/head/auth/verify`, the auth check is trusted.
Handler signature:
```rust
fn handle(input: Value, context: OperationContext) -> ResponseEnvelope;
```
### ResponseEnvelope
The universal return type from all three dispatch paths:
```rust
pub struct ResponseEnvelope {
pub request_id: String,
pub result: Result<Value, CallError>,
}
pub struct CallError {
pub code: String,
pub message: String,
pub retryable: bool,
}
```
Local dispatch produces `ResponseEnvelope` with no serialization. irpc service
dispatch produces postcard-encoded results that are decoded into
`ResponseEnvelope`. Remote dispatch receives `call.responded` EventEnvelope
frames and maps them to `ResponseEnvelope`. The handler always gets the same
type back.
### Relationship to @alkdev/pubsub and @alkdev/operations
The call protocol in core is a Rust reimplementation of the same protocol
defined in `@alkdev/operations`. The TypeScript implementation provides:
- `PendingRequestMap` — request/response correlation
- `CallHandler` — bridges pubsub events to operation registry
- `OperationSpec`, `AccessControl`, `Identity` — type definitions
The Rust implementation mirrors these types and behaviors. TypeScript consumers
continue using `@alkdev/operations` over `@alkdev/pubsub` adapters (including
the `event-target-alknet` adapter). Rust consumers use core's registry directly.
Both speak the same wire protocol and can interoperate.
The key principle: **the same `EventEnvelope` can flow from a Rust handler
through core, out over SSH channel, into a JavaScript pubsub adapter, and
be dispatched through `@alkdev/operations`'s call handler** — with zero
translation at the wire level.
### Agent Service Pattern (Downstream Application Concern)
An agent service — coordinating between LLM providers and tool calls — is a
primary downstream use case for the call protocol. It would be just another set
of registered operations with no special treatment:
- `/head/agent/chat` — send a message, get a completion. Routes to the
appropriate LLM provider based on available workers and configuration.
- `/head/agent/complete` — streaming completion. Yields tokens as they arrive.
- `/head/sessions/list` — list session histories (backed by Honker or other
durable storage).
- `/head/sessions/history` — retrieve a specific session's message history.
The agent service uses OperationEnv to invoke tools on workers. **This is a
downstream application concern, not a core requirement.** The call protocol
enables it by providing the universal composition mechanism (ADR-033), but the
agent service itself is built on top, not into the core.
## Constraints
- The call protocol does not depend on Honker, SQLite, or any database. The
`PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
- Operation specs use JSON Schema. Complex sub-structures (postcard, protobuf)
can be carried as base64-encoded blobs in the `payload`, but the envelope
itself is always JSON.
- Service discovery (`/services/list`, `/services/schema`) is read-only. No
admin operations are exposed through the call protocol itself.
- Batch is not a protocol primitive. Multiple `call.requested` events with
correlated `requestId`s provide equivalent semantics.
- The node prefix in the operation path is a routing mechanism, not a security
boundary. ACL is enforced at the `AccessControl` level, not by path prefix
alone. A worker that exposes `/dev1/bash/exec` can restrict access via
`required_scopes` — not every authenticated identity should have shell access.
- **OperationEnv composition model matches the `@alkdev/operations` behavioral
contract**: namespace + operation name → invoke with input, return output.
The Rust implementation may differ in structure but must preserve this
contract (ADR-033).
- **irpc is explicitly positioned as one dispatch backend for OperationEnv**
(ADR-033, ADR-028). It is not a replacement for the call protocol or for
OperationEnv.
- **Phase 1 is local dispatch only.** irpc service dispatch and remote dispatch
are contracted in this spec but not built yet. The `OperationEnv::local()`
path is the Phase 1 implementation.
## Open Questions
- **OQ-20**: How does the head track which workers expose which operations when
workers connect and disconnect? Registration on connect and cleanup on
disconnect, or heartbeat-based discovery? See
[open-questions.md](open-questions.md).
- **OQ-22**: ~~Should the call protocol support streaming inputs (client streaming
in gRPC terms)?~~ Resolved — deferred. Current model covers all identified use
cases. See [open-questions.md](open-questions.md).
- **~~OQ-IF-01~~**: ~~How does the `Interface` session type relate to the call
protocol's `EventEnvelope` stream?~~ Resolved — `InterfaceSession::recv()`
returns `Option<InterfaceEvent>` where `InterfaceEvent` carries
`EventEnvelope` + `Identity`. `InterfaceSession::send()` accepts `EventEnvelope`.
The `SshSession` bridge implements this over the `alknet-control:0` channel.
For `MessageInterface`, `InterfaceRequest`/`InterfaceResponse` normalize
request/response pairs. See [interface.md](interface.md) and ADR-035.
- **OQ-P2-01**: Should `MessageInterface` and `StreamInterface` share a common
trait? See [interface.md](interface.md) and [open-questions.md](open-questions.md).
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [018](decisions/018-control-channel-for-pubsub.md) | Control channel for pubsub | Reserved destination for event bus |
| [024](decisions/024-bidirectional-call-protocol.md) | Bidirectional call protocol | Generalizes ADR-018, both sides can call |
| [025](decisions/025-handler-spec-separation.md) | Handler/spec separation | Downstream registers operations without modifying core |
| [028](decisions/028-auth-irpc-service.md) | Auth as irpc service | irpc is one dispatch backend for OperationEnv |
| [033](decisions/033-operationenv-irpc-call-protocol.md) | OperationEnv | Universal composition with three dispatch paths |
| [035](decisions/035-streaminterface-messageinterface-split.md) | StreamInterface/MessageInterface | Call protocol accepts events from both interface categories |
## Phase 2 Implementation Notes
- `SshSession::recv()` and `SshSession::send()` now functional — bridged to call protocol via `alknet-control:0` SSH channel using `ControlChannelBridge` with mpsc channels
- `FrameFramedReader`/`FrameFramedWriter` added to `call::frame` for async length-prefixed EventEnvelope I/O
- `RawFramingSession` implemented with first-frame auth: first frame's payload extracted as AuthToken, resolved via `IdentityProvider::resolve_from_token()`, session transitions to authenticated state on success
- `OperationEnv.credentials(service)` method added for outbound credential resolution (ADR-036)
- `CredentialProvider` trait and `CredentialSet` enum defined in `alknet_core::credentials`
## References
- [auth.md](auth.md) — Identity and `IdentityProvider` trait
- [napi-and-pubsub.md](napi-and-pubsub.md) — NAPI wrapper and pubsub adapter
- [server.md](server.md) — Channel handling and control channel routing
- [transport.md](transport.md) — Transport abstraction
- [identity.md](identity.md) — Identity struct, IdentityProvider trait
- [interface.md](interface.md) — Interface layer, EventEnvelope stream from interfaces
- [configuration.md](configuration.md) — ForwardingPolicy, service metadata
- [services.md](services.md) — OperationEnv, OperationContext, irpc service layer
- `@alkdev/pubsub` — TypeScript event target adapters and `EventEnvelope`
- `@alkdev/operations` — TypeScript call protocol, `OperationSpec`, registry
- `@alkdev/storage``peer_credentials` table, ACL graph, `Identity`
- [irpc](/workspace/irpc) — iroh streaming RPC (postcard-only, Rust-to-Rust)
- [iroh](/workspace/iroh) — P2P QUIC transport

View File

@@ -1,209 +0,0 @@
---
status: reviewed
last_updated: 2026-06-02
---
# Client
## What
The alknet client establishes an SSH session to a server (via pluggable transport) and exposes a local SOCKS5 proxy for routing traffic through that session. Port forwarding (`-L` / `-R` style) covers specific service access like Postgres or Redis.
## Why
Users need a way to route traffic through the SSH tunnel. SOCKS5 is the primary interface — it's standard, well-supported by browsers and CLI tools, and needs no privileges. Port forwarding covers specific service access. For VPN-like "route all traffic" behavior, users run `tun2proxy` alongside alknet (ADR-014).
## Architecture
### Client Components
```
┌────────────────────────────────────────────────────────┐
│ alknet connect │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ SOCKS5 │ │ Port │ │ Remote │ │
│ │ Server │ │ Forward │ │ Forward │ │
│ │ :1080 │ │ -L spec │ │ -R spec │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ Channel Manager │ │
│ │ (opens direct-tcpip, │ │
│ │ forwarded-tcpip streams) │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ SSH Client (russh) │ │
│ │ Handle<ClientHandler> │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ Transport │ │
│ │ (Tcp / Tls / Iroh) │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
```
### SOCKS5 Server
The primary client interface. Listens on a local port (default `127.0.0.1:1080`), accepts SOCKS5 connections, and for each connection:
1. Reads the SOCKS5 handshake (auth method negotiation, target address)
2. Opens a `channel_open_direct_tcpip(target_host, target_port, originator_addr, originator_port)` on the SSH session
3. Converts the SSH channel to a stream via `channel.into_stream()`
4. Runs `tokio::io::copy_bidirectional(&mut local_socket, &mut ssh_stream)` to proxy data
Supports SOCKS5h (domain names resolved server-side) by default. This prevents DNS leaks — the client never resolves target hostnames locally, sending them to the server for resolution instead. This is consistent with the project's privacy design (ADR-006).
### Port Forwarding
Local port forwards (`-L local_addr:local_port:remote_host:remote_port`):
1. Bind `TcpListener` on `local_addr:local_port`
2. For each accepted connection, open `channel_open_direct_tcpip(remote_host, remote_port, ...)`
3. Proxy bytes bidirectionally via `copy_bidirectional`
Remote port forwards (`-R remote_addr:remote_port:local_host:local_port`):
1. Send `tcpip_forward(remote_addr, remote_port)` to request the server listen on a port
2. When the handler receives `server_channel_open_forwarded_tcpip`, connect to `local_host:local_port`
3. Proxy bytes bidirectionally
### Channel Manager
The channel manager owns the `Arc<client::Handle<ClientHandler>>` and provides methods:
- `open_direct_tcpip(host, port)` — open a tunnel channel to a remote host
- `open_streamlocal(socket_path)` — open a tunnel to a Unix socket
- `request_tcpip_forward(addr, port)` — request remote listening
- `cancel_tcpip_forward(addr, port)` — cancel remote listening
It also handles reconnection: if `handle.is_closed()` returns true, attempt reconnection with exponential backoff.
### Reconnection
On transport failure:
1. Detect via `handle.is_closed()` or transport read error
2. Exponential backoff reconnect (1s, 2s, 4s, ... max 30s)
3. Re-establish transport connection
4. Re-authenticate SSH session
5. Notify SOCKS5 server and port forwards (in-flight connections fail, new connections work)
Reconnection is always enabled. The backoff caps at 30 seconds and continues indefinitely until the user terminates the process. Existing TCP connections through the tunnel are lost on reconnect — this is acceptable and consistent with how VPN connections behave.
The channel manager orchestrates reconnection: it creates a new transport stream (by calling `transport.connect()` again) and establishes a new SSH session over it (ADR-004). This is a full reconnect — there is no "SSH reconnects over the same transport." Port forward listeners (`-L`, `-R`) are re-registered with the new session after reconnection.
### Programmatic Configuration (ADR-011)
The client uses programmatic configuration — no `~/.ssh/config` parsing, no custom config files. Configuration comes from:
1. **CLI flags**: `--server`, `--identity`, `--transport`, etc.
2. **Library API**: `ConnectOptions` and `ServeOptions` structs in `alknet-core`, constructable programmatically
3. **Environment variables**: `ALKNET_SERVER`, `ALKNET_IDENTITY` as convenience defaults
This approach avoids cross-platform path issues (`~` expansion, Windows `USERPROFILE`) and makes the library API clean for programmatic consumers like the NAPI wrapper. Keys can be provided as file paths or in-memory data.
### Key Material Format
Key inputs (`--identity`, `--authorized-keys`, `--cert-authority`, `--key`) accept either:
- **File path**: A filesystem path to a key file (e.g., `~/.ssh/id_ed25519`, `/etc/alknet/ca.pub`)
- **In-memory data**: Raw key bytes provided programmatically via the library API or NAPI wrapper (as `Vec<u8>` in Rust, `Buffer` in Node.js)
The accepted format is **OpenSSH key format** (the format used by `ssh-keygen` and OpenSSH's `~/.ssh/` files). This includes:
- Private keys: OpenSSH format (begins with `-----BEGIN OPENSSH PRIVATE KEY-----`)
- Public keys: OpenSSH format (e.g., `ssh-ed25519 AAAA... user@host`)
- Certificate authority keys: OpenSSH public key format
- Authorized keys files: Standard OpenSSH `authorized_keys` format
PEM-encoded keys (PKCS#1, PKCS#8) are not supported. Use OpenSSH format keys throughout.
### CLI Interface
```bash
# Basic connection (TCP, default port 22)
alknet connect --server example.com --identity ~/.ssh/id_ed25519
# With TLS
alknet connect --server example.com:443 --transport tls --identity ~/.ssh/id_ed25519
# With TLS + insecure (self-signed certs)
alknet connect --server example.com:443 --transport tls --identity ~/.ssh/id_ed25519 --insecure
# With iroh (no public IP needed)
alknet connect --peer <endpoint-id> --transport iroh --identity ~/.ssh/id_ed25519
# With iroh + custom relay
alknet connect --peer <endpoint-id> --transport iroh --identity ~/.ssh/id_ed25519 --iroh-relay https://relay.example.com
# With iroh + proxy (transport chaining)
alknet connect --peer <endpoint-id> --transport iroh --identity ~/.ssh/id_ed25519 --proxy socks5://127.0.0.1:1080
# SOCKS5 on custom port
alknet connect --server example.com --socks5 127.0.0.1:1080 --identity ~/.ssh/id_ed25519
# With port forwards
alknet connect --server example.com --forward 5432:db.internal:5432 --forward 6379:redis.internal:6379
# All options
alknet connect \
--server <addr> \ # TCP/TLS server address (required for tcp/tls)
--peer <endpoint-id> \ # iroh endpoint ID, base58-encoded (required for iroh)
--transport tcp|tls|iroh \ # Transport mode
--identity <path-or-buffer> \ # SSH private key (path or in-memory)
--socks5 <addr:port> \ # SOCKS5 listen address (default: 127.0.0.1:1080)
--forward <spec> \ # Port forward spec (repeatable)
--remote-forward <spec> \ # Remote port forward spec (repeatable)
--proxy <url> \ # Upstream proxy (socks5:// or http://)
--iroh-relay <url> \ # iroh relay URL (default: n0 relay)
--tls-server-name <host> \ # SNI hostname for TLS
--insecure # Accept self-signed TLS certs
```
## Constraints
- SOCKS5 is always enabled when `alknet connect` runs (it's the primary interface). Port forwards are optional.
- The client does not log tunnel destinations. The SOCKS5 server connects and proxies — no logging of SOCKS5 request targets.
- Authentication is Ed25519 public key or OpenSSH certificate (ADR-012). No password authentication over SSH.
- Only one SSH session per `alknet connect` process. Multiple sessions = multiple processes (or a future multiplexer).
- No `~/.ssh/config` parsing. Configuration is programmatic via CLI flags, env vars, or library API structs (ADR-011).
- VPN-like "route all traffic" behavior is provided by running `tun2proxy --proxy socks5://127.0.0.1:1080` alongside the client, not by a built-in TUN interface (ADR-014).
- The CLI `alknet connect` command manages a full SSH session with SOCKS5 and port forwarding. The NAPI `connect()` function is a different operation — it opens a single SSH channel as a Duplex stream for programmatic use, with no SOCKS5 server or port forwarding. See napi-and-pubsub.md for details.
## Graceful Shutdown
On SIGTERM or SIGINT:
1. Stop accepting new SOCKS5 connections and port forward connections
2. Send an SSH disconnect message to the server
3. Wait for in-flight channel data to drain (brief timeout, ~2 seconds)
4. Close the transport stream
5. Exit
In-flight connections are not preserved across shutdown — they receive a connection reset. This matches the behavior of standard SSH tunnel tools.
## Error Handling
Error handling follows the project's layered pattern (see overview.md):
- **Transport errors**: Trigger reconnection with exponential backoff (see Reconnection section above). If reconnection fails indefinitely, the process continues retrying until the user terminates it.
- **Auth errors**: Cause reconnection retry. After repeated auth failures, the SOCKS5 server and port-forward listeners remain active but new channel opens fail until reconnection succeeds.
- **Channel-level errors**: Individual channel failures (target unreachable, proxy failure) close that channel without affecting the SSH session or other channels.
- **CLI errors**: Reported to stderr with a non-zero exit code. Fatal errors (invalid flags, key file not found) exit immediately.
## Open Questions
None — all resolved.
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [005](decisions/005-socks5-before-tun.md) | SOCKS5 first | SOCKS5 is the primary interface; TUN is external (tun2proxy) |
| [006](decisions/006-no-logging-of-tunnel-destinations.md) | No logging of destinations | Client does not log SOCKS5 request targets (consistent with ADR-006) |
| [011](decisions/011-no-ssh-config-programmatic-api.md) | Programmatic-first API | No file-based config; options are structs, env vars, or CLI flags |
| [012](decisions/012-auth-ed25519-and-cert-authority.md) | Key + cert-authority | No password auth; OpenSSH cert-authority for multi-user |
| [019](decisions/019-proxy-dual-semantics.md) | Proxy dual semantics | `--proxy` routes transport on client, data on server |

View File

@@ -1,329 +0,0 @@
---
status: draft
last_updated: 2026-06-09
---
# Configuration
## What
Alknet's configuration is split into `StaticConfig` (immutable after startup) and
`DynamicConfig` (hot-reloadable at runtime), with `ArcSwap` providing lock-free
reads on the hot path. `ConfigService` wraps reloads behind an irpc protocol
for production deployments.
## Why
Three specific failures motivated the split (ADR-030):
1. No hot reload of authentication credentials — adding a key requires a restart.
2. No port forwarding access control — any authenticated client has unrestricted
access (ADR-031).
3. No structured configuration beyond CLI flags — operators need config files
and the NAPI layer needs programmatic reload.
The split is clean: anything that affects SSH handshake or socket binding is
static; anything checked per-connection or per-channel is dynamic.
## Architecture
### StaticConfig
Immutable after startup. Constructed from `ServeOptions` (the builder pattern
is preserved per ADR-011). Contains:
- Transport mode, listen address
- TLS config (cert, key)
- iroh config (relay URL)
- Stealth mode flag
- Host key, host key algorithm
- Max auth attempts, max connections per IP
- Proxy config
Changing any of these requires a restart.
### DynamicConfig
Hot-reloadable at runtime via `ArcSwap<DynamicConfig>`. Contains:
- `AuthPolicy` — authorized keys, certificate authorities, token config
- `ForwardingPolicy` — allow/deny rules for channel targets (ADR-031)
- `RateLimitConfig` — rate limiting parameters
`ArcSwap` provides lock-free reads. Every `auth_publickey()` and
`channel_open_direct_tcpip()` call does a single `Arc` dereference — zero cost
compared to the current approach. Writes are atomic: `store()` swaps the
pointer.
### API Keys
`DynamicConfig.auth` also includes API keys for service accounts and HTTP
interface auth (ADR-037):
```toml
[[auth.api_keys]]
prefix = "alk_"
hash = "sha256:abc..."
scopes = ["relay:connect"]
description = "dashboard service account"
ttl = "30d" # optional
```
API keys are verified by `ConfigIdentityProvider::resolve_from_token()` — if
the token starts with the configured prefix, it's treated as an API key and
verified by SHA-256 hash lookup. Otherwise, it's treated as an Ed25519 AuthToken.
Both paths produce the same `Identity` result.
### ConfigReloadHandle
```rust
pub struct ConfigReloadHandle {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigReloadHandle {
pub fn reload(&self, new_config: DynamicConfig) { ... }
}
```
Obtained from `Server::run()`. Passed to NAPI or CLI for explicit reload.
### ConfigServiceImpl
The Phase 1 implementation of config service logic, backed by
`ArcSwap<DynamicConfig>`. Where `ConfigIdentityProvider` wraps the auth section
of `DynamicConfig`, `ConfigServiceImpl` wraps the forwarding and rate-limit
sections. Both are ArcSwap-backed and share the same `DynamicConfig` instance.
```rust
pub struct ConfigServiceImpl {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigServiceImpl {
pub fn forwarding_policy(&self) -> Arc<ForwardingPolicy> {
self.dynamic.load().forwarding.clone()
}
pub fn rate_limits(&self) -> Arc<RateLimitConfig> {
self.dynamic.load().rate_limits.clone()
}
pub fn reload(&self, new_config: DynamicConfig) {
self.dynamic.store(Arc::new(new_config));
}
}
```
Phase 1 deploys `ConfigServiceImpl` directly — no irpc service boundary. The
`ConfigProtocol` irpc service (behind feature flag) wraps `ConfigServiceImpl`
for production deployments that use the service layer. This mirrors the
`ConfigIdentityProvider` / `AuthProtocol` pattern from [identity.md](identity.md)
and ADR-028.
### ConfigService irpc Service
```rust
enum ConfigProtocol {
GetForwardingPolicy,
GetRateLimits,
ReloadForwarding { policy: ForwardingPolicy },
ReloadRateLimits { limits: RateLimitConfig },
}
```
Behind the `irpc` feature flag. For production deployments that use the service
layer. For minimal deployments, direct `ConfigReloadHandle::reload()` is
sufficient.
### ForwardingPolicy
Part of DynamicConfig (ADR-031). Evaluated per-channel-open, matched against
the authenticated `Identity`. Rules are evaluated in order; first match wins.
Default determines fallback.
```rust
pub struct ForwardingPolicy {
pub default: ForwardingAction,
pub rules: Vec<ForwardingRule>,
}
```
### TOML Config File
Optional convenience input format (amends ADR-011, does not replace
programmatic API). Covers static config plus initial auth/forwarding paths.
```toml
[server]
# Stream-based listener: TLS + SSH on port 443
[[listeners]]
type = "stream"
transport = "tls"
interface = "ssh"
listen = "0.0.0.0:443"
[server.tls]
cert = "/etc/alknet/tls/cert.pem"
key = "/etc/alknet/tls/key.pem"
# Stream-based listener: TCP + SSH on port 22
[[listeners]]
type = "stream"
transport = "tcp"
interface = "ssh"
listen = "0.0.0.0:22"
# Stream-based listener: iroh P2P
[[listeners]]
type = "stream"
transport = "iroh"
iroh_relay = "https://relay.alk.dev"
# Message-based listener: HTTP on port 443 (with stealth)
[[listeners]]
type = "http"
listen = "0.0.0.0:443"
tls = true
stealth = true
# Message-based listener: HTTP on port 8080 (separate, no stealth)
# [[listeners]]
# type = "http"
# listen = "0.0.0.0:8080"
# tls = false
# stealth = false
# Message-based listener: DNS on port 53
# [[listeners]]
# type = "dns"
# listen = "0.0.0.0:53"
# tls = false
[auth]
host_key = "/etc/alknet/ssh/host_key"
[auth.ssh]
authorized_keys = [...]
[auth.token]
enabled = true
max_token_age = "5m"
[[auth.api_keys]]
prefix = "alk_"
hash = "sha256:abc..."
scopes = ["relay:connect"]
description = "dashboard service account"
ttl = "30d"
[forwarding]
default = "deny"
[[forwarding.rules]]
target = "localhost:*"
action = "allow"
```
### NAPI Reload API
```typescript
interface AlknetServer {
reloadAuth(auth: { authorizedKeys?: Buffer, certAuthority?: Buffer }): void;
reloadForwarding(policy: ForwardingPolicyConfig): void;
reloadAll(config: DynamicConfig): void;
}
```
### Multi-Transport Listeners
A head node may accept connections on multiple transports and interfaces simultaneously.
Listeners come in two categories: stream-based (Transport + StreamInterface pairs) and
message-based (self-contained HTTP or DNS servers).
```rust
pub enum ListenerConfig {
Stream {
transport: TransportKind,
interface: StreamInterfaceKind,
},
Http {
bind_addr: SocketAddr,
tls: bool,
stealth: bool, // byte-peek protocol detection on shared port
},
Dns {
bind_addr: SocketAddr,
tls: bool,
},
}
```
For stream-based listeners, `Server::run()` spawns one accept loop per listener.
For HTTP listeners, it spawns an axum server. For DNS listeners, it spawns a DNS
server. All share `DynamicConfig`, `ConnectionRateLimiter`, sessions, and
shutdown signal.
```toml
[[listeners]]
transport = "tls"
listen = "0.0.0.0:443"
stealth = true
[[listeners]]
transport = "tcp"
listen = "0.0.0.0:22"
[[listeners]]
transport = "iroh"
iroh_relay = "https://relay.alk.dev"
```
### CLI vs Programmatic Behavior
| Interface | Static config | Dynamic config | Reload mechanism |
|-----------|--------------|----------------|------------------|
| CLI | Flags + optional `--config` file | Loaded at startup from `--authorized-keys` | None (restart to change) |
| Core Rust | `StaticConfig` struct | `AuthProtocol` (irpc) or `ConfigIdentityProvider` (ArcSwap) | `ConfigProtocol::ReloadDynamicConfig` or `ConfigReloadHandle::reload()` |
| NAPI | `serve()` options | Same | `server.reloadAuth()`, `server.reloadForwarding()` |
## Constraints
- `StaticConfig` cannot be changed after startup. Changing transport mode,
listen address, TLS config, or host key requires a restart.
- `DynamicConfig` is reloaded atomically via `ArcSwap`. Existing connections
continue with their current config; new connections get the new config.
- Config file is optional. `ServeOptions` builder pattern remains the primary
API (amends ADR-011, does not supersede it).
- No file watching (OQ-13 resolved: potential attack vector, unnecessary
complexity).
- Client configuration stays as `ConnectOptions` — no `ArcSwap` needed.
## Open Questions
- None. All configuration-related questions are resolved per ADR-030, ADR-031,
and the resolved OQs in [open-questions.md](open-questions.md).
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [030](decisions/030-static-dynamic-config-split.md) | Static/dynamic config split | Immutable transport vs. reloadable auth/forwarding |
| [011](decisions/011-no-ssh-config-programmatic-api.md) | Programmatic-first API | Amended, not superseded — TOML is convenience layer |
| [031](decisions/031-forwarding-policy.md) | Forwarding policy | Rule-based allow/deny, TransportKind-aware |
| [029](decisions/029-identity-core-type.md) | Identity as core type | DynamicConfig.auth consumed by IdentityProvider |
| [028](decisions/028-auth-irpc-service.md) | Auth as irpc service | ConfigService wraps DynamicConfig reloads |
## Phase 2 Implementation Notes
- `DynamicConfig.auth` now includes `api_keys: Vec<ApiKeyEntry>` (ADR-037)
- `DynamicConfig.credentials: HashMap<String, CredentialSet>` added for static outbound credentials (ADR-036)
- `ListenerConfig` restructured from flat struct to enum: `Stream { transport, interface }`, `Http { config: HttpListenerConfig }`, `Dns { config: DnsListenerConfig }` (ADR-035)
- `HttpListenerConfig` and `DnsListenerConfig` builder-pattern structs added
- `ListenerConfig::validate()` now validates all three variants
## References
- [research/configuration.md](../research/configuration.md) — Full analysis and proposed solution
- [identity.md](identity.md) — IdentityProvider trait, DynamicConfig.auth
- [ADR-013](decisions/013-fail2ban-friendly-logging.md) — Rate limiting parameters

View File

@@ -0,0 +1,61 @@
---
status: draft
last_updated: 2026-06-22-22
---
# alknet-call
Structured RPC over QUIC: operations, request/response, streaming subscriptions, and service discovery. Implements `ProtocolHandler` on ALPN `alknet/call`.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [call-protocol.md](call-protocol.md) | draft | CallAdapter, EventEnvelope framing, stream model, PendingRequestMap, bidirectional calls |
| [operation-registry.md](operation-registry.md) | draft | OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, irpc integration |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | CallAdapter registers on ALPN `alknet/call` |
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | CallAdapter implements ProtocolHandler |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-call depends on alknet-core and irpc |
| [013](../../decisions/013-rust-canonical-implementation.md) | Rust as Canonical Implementation Language | Adapter traits defined in Rust; TS is reference/browser adaptation |
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | AuthContext passed to call handlers |
| [005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc as Call Protocol Foundation | irpc provides framing and service dispatch |
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | `alknet/call` ALPN, one ALPN per connection |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | CallAdapter receives Connection, not BiStream |
| [008](../../decisions/008-secret-service-integration.md) | Vault Integration Point | Vault accessed at assembly layer, not on the wire |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Static handler registration |
| [012](../../decisions/012-call-protocol-stream-model.md) | Call Protocol Stream Model | Bidirectional streams, EventEnvelope, ID-based correlation |
| [014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Secret Material Flow and Capability Injection | Call protocol carries no secret material; capabilities injected at assembly layer |
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| [016](../../decisions/016-abort-cascade-for-nested-calls.md) | Abort Cascade for Nested Calls | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| [017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | Call Protocol Client and Adapter Contract | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| [022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Handler Registration, Provenance, and Composition Authority | Registration bundle carries provenance, composition authority, scoped env, capabilities |
| [023](../../decisions/023-operation-error-schemas.md) | Operation Error Schemas | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity |
| [024](../../decisions/024-operation-registry-layering.md) | Operation Registry Layering | Curated (static) + session/connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-07 | Call protocol scope within a connection | resolved (ADR-012) | Stream model, multiplexing, scope |
| OQ-13 | Operation path format and routing scope | resolved | `/{service}/{op}` is the correct design; remote dispatch is a separate layer |
| OQ-14 | Batch operation semantics | resolved | Correlated `call.requested` events is the correct protocol design |
| OQ-16 | Safe vault operations for call protocol exposure | resolved (ADR-014) | None exposed for now |
| OQ-19 | Session-scoped operation registries | resolved | Agent-written operations overlaid on curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Generalized by ADR-024 to cover connection-scoped overlays. |
## Key Design Principles
1. **One connection, full access**: An `alknet/call` connection gives access to the entire operation registry — calls, subscriptions, batch, schema.
2. **Protocol is symmetric**: Both sides can initiate calls. The server calling a client uses the same EventEnvelope format and correlation.
3. **Stream-agnostic correlation**: PendingRequestMap correlates by request ID, not by stream. The protocol works with any stream arrangement.
4. **Operation registry is layered**: The curated layer (`Local` provenance) is static — registered at startup by the CLI binary, immutable for the process lifetime. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic overlays at their respective scopes (per-session, per-connection). The registry supports JSON Schema discovery. See ADR-024.
5. **irpc is one dispatch backend**: Local operations dispatch directly. irpc service calls (in-process, type-safe) are internal. The call protocol is the external interface.
6. **Local dispatch only**: The operation registry dispatches to local handlers. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer, not a modification to alknet-call's path format.
7. **No secret material on the wire**: The call protocol carries no private keys, API keys, mnemonics, or decrypted credentials. Handlers receive outbound credentials through `OperationContext.capabilities`, injected at the assembly layer. See ADR-014.
8. **Abort cascades to descendants**: `call.aborted` for a parent request cascades to all non-terminal descendants. Default `abort-dependents`; `continue-running` opt-in. See ADR-016.
9. **Internal calls switch authority context, not skip ACL**: The `internal` flag marks composition-originated calls. ACL runs against the handler's composition authority, not the caller's and not as a blanket skip. Operations have External/Internal visibility. Scoped composition env bounds reachability. See ADR-015, ADR-022.
10. **Provenance determines composition capability**: Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) are forwarding stubs — they don't get composition authority or a scoped env. The assembly layer is the sole grantor of composition authority. See ADR-022.

View File

@@ -0,0 +1,545 @@
---
status: draft
last_updated: 2026-06-23
---
# Call Protocol
The wire protocol, stream model, framing, and adapter that alknet-call implements on ALPN `alknet/call`.
## What
The call protocol is a bidirectional, stream-agnostic RPC protocol that runs over QUIC bidirectional streams within a single `alknet/call` connection. It supports request/response calls, streaming subscriptions, batch operations, and service discovery — all using the same EventEnvelope wire format.
The `CallAdapter` implements `ProtocolHandler` for ALPN `alknet/call`. It receives a `Connection` from the endpoint, accepts bidirectional streams, and dispatches incoming `EventEnvelope` messages to the operation registry.
## Why
The call protocol is the primary programmatic interface to an alknet node. While SSH provides interactive shell access and HTTP provides REST APIs, the call protocol provides structured, discoverable RPC — the same interface that NAPI clients, MCP tools, and other automation consumers use.
The protocol must be:
- **Cross-language**: JSON wire format consumable from TypeScript, Python, any language
- **Bidirectional**: Both sides can initiate calls (server-to-client is as natural as client-to-server)
- **Stream-agnostic**: QUIC provides stream multiplexing; the protocol shouldn't impose additional constraints
- **Discoverable**: Clients can query what operations exist and their schemas
See ADR-005 for the decision to use irpc as the call protocol's foundation and ADR-012 for the stream model decision.
## Architecture
### CallAdapter
The `CallAdapter` implements `ProtocolHandler`:
```rust
pub struct CallAdapter {
/// Layer 0 — the curated operation registry. Immutable after startup.
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
/// Layer 1 — optional session-overlay source (agent crate supplies this;
/// None for non-agent deployments). See ADR-024, OQ-19.
session_source: Option<Arc<dyn SessionOverlaySource + Send + Sync>>,
/// Default timeout for wire calls (30s). Composed calls inherit the
/// parent's remaining deadline via `OperationContext.deadline`.
default_timeout: Duration,
}
impl CallAdapter {
/// Non-agent deployment: no session overlay, default timeout.
pub fn new(
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Self {
Self { registry, identity_provider, session_source: None,
default_timeout: Duration::from_secs(30) }
}
/// Agent deployment: supply a session-overlay source. The agent crate
/// implements `SessionOverlaySource`; alknet-call defines the trait.
pub fn with_session_source(mut self, source: Arc<dyn SessionOverlaySource + Send + Sync>) -> Self {
self.session_source = Some(source);
self
}
/// Override the default timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.default_timeout = timeout;
self
}
}
/// Session overlay integration point (ADR-024). Defined in alknet-call
/// because `CallAdapter` must name the type — alknet-call cannot depend on
/// alknet-agent (agent depends on call, not reverse). The agent crate
/// implements this trait; alknet-call defines it. This is the same pattern
/// as `IdentityProvider` (ADR-004: core defines the trait, handlers impl it).
///
/// The session overlay is an `OperationEnv` impl that wraps the curated base
/// (Layer 0). The `CallAdapter` composes it into the root
/// `OperationContext.env` per incoming call when a session is active. The
/// lookup mechanism (session ID in metadata, payload field, connection-bound
/// session state) belongs to the agent crate — this trait is the integration
/// point, not the lookup policy.
pub trait SessionOverlaySource: Send + Sync {
/// Returns the session overlay env for the given call, if a session is
/// active. `None` means no session is active for this call — the root
/// env is `curated base + connection overlay` (no session layer).
/// The agent crate determines how to map a call to its session.
fn overlay_for(&self, context: &OperationContext) -> Option<Arc<dyn OperationEnv + Send + Sync>>;
}
The `CallAdapter` holds the static curated registry and an optional
session-overlay source. Per-connection imported-ops overlays (Layer 2,
ADR-024) are held with the connection and composed into the root
`OperationContext.env` per incoming call. See ADR-024 for the layering
model and `compose_root_env` below.
### CallConnection
A `CallConnection` represents an established `alknet/call` connection,
regardless of which side opened it (ADR-017). It holds the connection's
imported-ops overlay (Layer 2, ADR-024) the set of `from_call`-imported
operations discovered when the connection was established.
```rust
/// An established alknet/call connection (either direction — accepted or
/// opened). Holds the connection's Layer 2 overlay (imported ops).
pub struct CallConnection {
/// The underlying QUIC connection (from endpoint.accept or CallClient.connect).
connection: Connection,
/// Layer 2 — this connection's imported-ops overlay. Populated by
/// `from_call` discovery when the connection is established. Each
/// imported op is a `HandlerRegistration` with `provenance: FromCall`.
/// This overlay is an `OperationEnv` impl that the `CallAdapter`
/// composes into the root `OperationContext.env` per incoming call.
imported_operations: Arc<RwLock<HashMap<String, HandlerRegistration>>>,
}
impl CallConnection {
/// Register an imported operation into this connection's overlay
/// (Layer 2, ADR-024). Called by `from_call` after discovery.
pub fn register_imported(&self, registration: HandlerRegistration) {
let name = registration.spec.name.clone();
self.imported_operations.write().insert(name, registration);
}
/// Register multiple imported operations (bulk variant for `from_call`).
pub fn register_imported_all(&self, registrations: Vec<HandlerRegistration>) {
let mut overlay = self.imported_operations.write();
for reg in registrations {
overlay.insert(reg.spec.name.clone(), reg);
}
}
/// Build an `OperationEnv` impl for this connection's overlay. Used by
/// the `CallAdapter` when composing the root `OperationContext.env`.
/// Returns an `OperationEnv` that dispatches to this connection's
/// imported ops (and reports `contains` only for ops in the overlay).
pub fn overlay_env(&self) -> Arc<dyn OperationEnv + Send + Sync>;
/// Call an operation on the remote peer (sends `call.requested`).
pub async fn call(&self, operation_id: &str, input: Value) -> ResponseEnvelope;
/// Subscribe to a streaming operation on the remote peer.
pub async fn subscribe(&self, operation_id: &str, input: Value) -> impl Stream<Item = ResponseEnvelope>;
/// Abort an in-flight request (sends `call.aborted`, cascades per ADR-016).
pub async fn abort(&self, request_id: &str);
}
```
**Layer 0 vs Layer 2 registration API** (ADR-024): `OperationRegistryBuilder`
builds Layer 0 (curated, immutable after startup) via `.with_local()` /
`.with_leaf()` / `.with()`. Layer 2 (per-connection) registration uses
`CallConnection::register_imported()` at runtime — the builder is
Layer-0-only; runtime overlay registration uses `CallConnection` methods.
When the connection drops, the overlay (and all imported ops) is dropped —
no explicit deregistration needed.
The adapter:
1. Accepts bidirectional streams on the connection
2. Reads length-prefixed JSON `EventEnvelope` frames from each stream
3. Resolves the peer's identity using `AuthContext` and `IdentityProvider`
4. Dispatches `call.requested` events to the operation registry
5. Writes response `EventEnvelope` frames back to the appropriate stream
6. Manages the `PendingRequestMap` for outgoing calls
### Stream Model
See ADR-012 for the full rationale.
The call protocol uses bidirectional QUIC streams with EventEnvelope framing. Key properties:
- **Either side can open streams**: The client opens a stream to call a server operation. The server opens a stream to call a client operation. Both use `open_bi()` and `accept_bi()`.
- **Correlation by request ID**: The `id` field in `EventEnvelope` correlates requests with responses. A response arriving on stream N can fulfill a request sent on stream M. The `PendingRequestMap` is keyed by ID, not by stream.
- **Stream usage is the client's choice**: A client may open one stream per operation, one stream for all operations, or any mix. The server processes EventEnvelopes regardless of stream origin.
- **One connection, full access**: A single `alknet/call` connection provides access to all operations (call, subscribe, batch, schema). No need for multiple connections or multiple ALPNs.
### Wire Format: EventEnvelope
Every message on the wire is a length-prefixed JSON `EventEnvelope`:
```rust
pub struct EventEnvelope {
pub r#type: String, // Event type
pub id: String, // Correlation key (request ID, subscription ID)
pub payload: Value, // serde_json::Value — schema depends on event type
}
// Frame: 4-byte big-endian length prefix + UTF-8 JSON body
```
The `Value` type is `serde_json::Value`. The envelope is JSON because it must be consumable from JavaScript, Python, and any language. The envelope itself stays JSON for cross-language compatibility.
Binary payloads (postcard, protobuf) are base64-encoded as a JSON string within the `payload` field. The convention is: if an operation's output schema specifies a binary field, the handler encodes it as a base64 string and the client decodes it. The `EventEnvelope` structure is not aware of this convention — it carries a `serde_json::Value` and does not interpret the payload. This is a handler-level concern, not a protocol-level concern.
This is the same framing used by irpc. The Rust implementation in alknet-call is canonical — the `@alkdev/pubsub` TypeScript adapters serve as a reference and browser adaptation, not a parallel implementation (see ADR-013).
### Event Types
Five event types carry request/response and subscription semantics:
| Event | Direction | Purpose |
|-------|-----------|---------|
| `call.requested` | Caller → Handler | Initiate a call or subscription |
| `call.responded` | Handler → Caller | Deliver a result (one for calls, many for subscriptions) |
| `call.completed` | Handler → Caller | Signal end of subscription stream |
| `call.aborted` | Either side | Cancel the call/subscription |
| `call.error` | Handler → Caller | Signal an error |
**A call is a subscribe that resolves after one event.** Both `call()` and `subscribe()` send the same `call.requested` event. The difference is consumption pattern:
- **call()**: Sends `call.requested`, resolves on first `call.responded`
- **subscribe()**: Sends `call.requested`, yields each `call.responded` until `call.completed` or `call.aborted`
The `id` field carries the `requestId` for correlation.
`call.completed` is sent only for subscriptions. A plain `call()` (request/response)
is complete after its single `call.responded`; no `call.completed` follows. The
`PendingRequestMap` entry for a `Call` is deleted on the first `call.responded`.
### `call.requested` Payload
The `payload` of a `call.requested` event has this shape:
```json
{
"operationId": "/fs/readFile",
"input": { ... },
"auth_token": "alk_..." // optional — see Identity Resolution below
}
```
- `operationId` — the operation to invoke, **with a leading slash** on the wire (e.g., `/fs/readFile`, `/agent/chat`, `/services/list`). This is the display form of the operation name. The registry stores names without the leading slash (`fs/readFile` — see [operation-registry.md](operation-registry.md#operationspec)); the wire format adds it. The `CallAdapter` strips the leading slash before registry lookup.
- `input` — the operation input, matching the operation's `input_schema` (JSON Schema). Always a `serde_json::Value`.
- `auth_token` — optional. If present, the `CallAdapter` resolves it via `IdentityProvider::resolve_from_token()` and the resulting `Identity` takes precedence over the connection-level identity for this request. See [Identity Resolution](#authcontext-and-identity-resolution) below.
The `call.requested` payload does **not** carry an abort policy field. The abort policy (`abort-dependents` vs `continue-running`, ADR-016) is set on `OperationContext` and propagated through `OperationEnv::invoke()` — the composing handler decides the child's policy, not the wire caller. See [Abort Cascade and Nested Calls](#abort-cascade-and-nested-calls) below.
**Leading-slash convention**: `operationId` on the wire always has a leading slash (`/fs/readFile`). `OperationSpec.name` in the registry and in `services/list` responses never has a leading slash (`fs/readFile`). `OperationSpec.path()` produces the wire form (`/fs/readFile`). This is a single rule applied consistently — do not mix the two forms.
### `call.error` Payload
```json
{
"code": "FILE_NOT_FOUND",
"message": "file not found: /etc/nonexistent",
"retryable": false,
"details": { "path": "/etc/nonexistent", "errno": 2 }
}
```
Error codes use an extensible string enum. The protocol defines the following **protocol-level codes** (emitted by the dispatch machinery, not by handlers):
- `NOT_FOUND` — operation not in registry (or Internal op called from wire)
- `FORBIDDEN` — access denied (insufficient scopes or unauthenticated)
- `INVALID_INPUT` — input doesn't match the operation's JSON Schema
- `INTERNAL` — handler error, panic, connection failure
- `TIMEOUT` — request timed out (retryable: true)
Operations may also declare **operation-level domain codes** in their `error_schemas` (ADR-023) — e.g., `FILE_NOT_FOUND`, `RATE_LIMITED`, `INSUFFICIENT_CREDITS`. These are emitted by handlers and carry a `details` payload conforming to the declared `ErrorDefinition.schema`. Protocol-level errors omit `details` or carry protocol-specific context (e.g., the operation name for `NOT_FOUND`).
Fields:
- `code` — the error code (protocol-level or operation-level)
- `message` — human-readable error message. For logging and debugging, not for programmatic handling. Clients should switch on `code`, not parse `message`.
- `retryable` — whether the caller should retry. `true` for transient failures, `false` for permanent ones.
- `details` — optional. When the code matches a declared `ErrorDefinition`, `details` conforms to that definition's schema. This is the typed error payload — it makes errors structured instead of string-matched. See ADR-023.
New error codes may be added in future versions. Clients should treat unknown error codes as `INTERNAL` with `retryable: false`.
### Wire Payload Schemas
The `payload` field of `EventEnvelope` has a different shape per event type:
| Event | `payload` shape |
|-------|----------------|
| `call.requested` | `{ "operationId": "/fs/readFile", "input": {...}, "auth_token": "alk_..." (optional) }` |
| `call.responded` | `{ "output": <Value> }` — the operation's output, matching `output_schema` |
| `call.completed` | `{}` — empty object (subscription stream end signal) |
| `call.aborted` | `{}` — empty object (cancellation signal; the `id` identifies which request) |
| `call.error` | `{ "code": "...", "message": "...", "retryable": bool, "details": {...} (optional) }` |
### `ResponseEnvelope` → `EventEnvelope` Conversion
Local dispatch produces `ResponseEnvelope { request_id, result: Result<Value, CallError> }`. The `CallAdapter` converts it to `EventEnvelope` for the wire:
| `ResponseEnvelope` | `EventEnvelope` |
|--------------------|-----------------|
| `Ok(value)` | `{ type: "call.responded", id: request_id, payload: { output: value } }` |
| `Err(call_error)` | `{ type: "call.error", id: request_id, payload: <serialized CallError> }` |
The `request_id` becomes the `id` field. For subscriptions, each `call.responded` is a separate `EventEnvelope` with the same `id`; `call.completed` is `{ type: "call.completed", id, payload: {} }`.
### Protocol Operations
The call protocol defines four top-level operations, expressed through event types and operation names:
| Operation | Event Pattern | Description |
|-----------|--------------|-------------|
| **call** | `call.requested``call.responded` or `call.error` | Request/response — one result |
| **subscribe** | `call.requested` → many `call.responded``call.completed` or `call.aborted` | Streaming — zero or more results |
| **batch** | multiple `call.requested` (different IDs) → multiple `call.responded` | Multiple operations in one round |
| **schema** | `call.requested` name `services/list` or `services/schema``call.responded` | Discover available operations |
Batch is not a separate event type — it's multiple `call.requested` events with different request IDs. The client sends them (on one or many streams) and correlates the responses by ID. See OQ-14.
### Bidirectional Calls
Both sides of the connection can initiate calls. The server can call operations on the client just as the client calls operations on the server.
```
Client Server
│ │
│── open_bi() → stream ─────────────────────────▶│
│── call.requested { id: "c1", ... } ────────────▶│ (client calls server)
│◀─ call.responded { id: "c1", ... } ───────────│
│ │
│◀─ open_bi() ← stream ──────────────────────────│
│◀─ call.requested { id: "s1", ... } ────────────│ (server calls client)
│── call.responded { id: "s1", ... } ───────────▶│
│ │
```
The server calls client operations using the same `PendingRequestMap` and the same `EventEnvelope` format. The operation registry on the client side dispatches `call.requested` events just like the server side.
This enables patterns where the server pushes notifications, requests configuration from the client, or orchestrates workflows that require the client to perform operations.
### Streaming Subscribe Example: LLM Chat
The subscribe operation pattern maps naturally to LLM streaming. An agent handler exposing `/agent/chat` as a subscription receives a `call.requested` event and streams `call.responded` events back as the LLM generates tokens. The output payloads use a normalized streaming UI format (e.g., Vercel AI SDK UI chunks — text-delta, tool-input-delta, etc.):
```
Client Server (agent handler)
│ │
│── open_bi() → stream ──────────────────────────────▶│
│── call.requested { id: "c1", │
│ operationId: "/agent/chat", │
│ input: { messages, model } } │
│ │ handler reads capabilities (API key)
│ │ handler makes HTTP request to LLM provider
│ │ handler normalizes provider SSE → UI chunks
│←─ call.responded { id: "c1", output: { type: "text-start", ... } } │
│←─ call.responded { id: "c1", output: { type: "text-delta", delta: "Hel" } }│
│←─ call.responded { id: "c1", output: { type: "text-delta", delta: "lo" } } │
│←─ call.responded { id: "c1", output: { type: "text-end", ... } } │
│←─ call.completed { id: "c1" } │
```
The API key used for the outbound LLM HTTP request comes from `OperationContext.capabilities`, not from the call protocol input and not from environment variables. See ADR-014 and [operation-registry.md → Capability Injection](operation-registry.md#capability-injection).
### PendingRequestMap
Manages in-flight calls and subscriptions. Correlates `call.responded` events back to the original `call.requested`:
```rust
pub struct PendingRequestMap {
pending: HashMap<String, PendingEntry>,
}
enum PendingEntry {
Call {
tx: oneshot::Sender<Result<Value, CallError>>,
timeout: Instant,
},
Subscribe {
tx: mpsc::Sender<Result<Value, CallError>>,
timeout: Option<Instant>,
},
}
```
When a `call.responded` event arrives:
- If `PendingEntry::Call` → resolve the oneshot, delete entry
- If `PendingEntry::Subscribe` → push to the mpsc channel, keep entry alive
When `call.completed` arrives on a subscription → close the mpsc channel, delete entry.
When `call.aborted` arrives → cancel/drop whichever side initiated it.
A `call.aborted` for an unknown `requestId` is silently discarded.
Timeouts prevent dangling entries. A background task sweeps expired entries periodically.
### CallAdapter Stream Handling
The `CallAdapter::handle()` method:
1. Spawns a task that continuously calls `connection.accept_bi()` to receive incoming streams
2. For each accepted stream, reads `EventEnvelope` frames using `FrameFramedReader`
3. Dispatches `call.requested` events to the operation registry
4. Writes response `EventEnvelope` frames using `FrameFramedWriter`
5. Manages `PendingRequestMap` for outgoing calls initiated by the server
For outgoing calls (server → client), the adapter:
1. Opens a bidirectional stream with `connection.open_bi()`
2. Sends `call.requested` on that stream
3. Adds the request ID to the `PendingRequestMap`
4. Reads responses from any stream, correlates by ID
### AuthContext and Identity Resolution
The `CallAdapter` receives an `AuthContext` from the endpoint. The call protocol resolves identity per-request, not per-connection:
**Resolution flow**:
1. The endpoint provides `AuthContext` with whatever identity it resolved at the TLS layer (e.g., client certificate fingerprint). This may be `None` — the `AuthContext.identity` field is `Option<Identity>`.
2. When a `call.requested` event arrives, the `CallAdapter` constructs an `OperationContext` with the connection-level `AuthContext.identity`.
3. If the `call.requested` payload includes an `auth_token` field, the `CallAdapter` resolves it using `IdentityProvider::resolve_from_token()`. If resolution succeeds, the resulting `Identity` replaces the connection-level identity in the `OperationContext`. If resolution fails, the request proceeds with the connection-level identity (which may be `None`).
4. The `OperationContext.identity` is passed to the `OperationRegistry` for ACL checking.
5. If `identity` is `None` and the operation's `AccessControl` has restrictions, the registry returns `FORBIDDEN` with message `"authentication required"`.
**Key point**: Identity is resolved per-request, not per-connection. This allows a single connection to upgrade authentication mid-session (e.g., after an `auth/login` operation returns a token), and allows different operations on the same connection to have different identity levels.
### Root OperationContext Construction
When a `call.requested` arrives from the wire, the `CallAdapter` constructs the root `OperationContext` — the entry point of the call tree. This is the counterpart to `OperationEnv::invoke()` (which constructs nested contexts with `internal: true`): the wire path sets `internal: false`, meaning ACL runs against the caller's `identity`, not a handler's composition authority (ADR-015, ADR-022).
```rust
// CallAdapter dispatch path — root context for an incoming wire request
fn build_root_context(
&self,
request_id: String,
operation_name: &str, // looked up in registry for the registration bundle
identity: Option<Identity>, // resolved per-request above (caller's identity)
) -> OperationContext {
let registration = self.registry.registration(operation_name);
OperationContext {
request_id,
parent_request_id: None, // wire request — top of the call tree
identity: identity.clone(), // caller's identity (inbound — gate credential)
// Composition authority from the registration bundle (ADR-022).
// None for leaves (FromOpenAPI/FromMCP/FromCall); Some for Local/Session.
// This is on the context for PROPAGATION to children via invoke(),
// not for the root's own ACL (which uses identity above).
handler_identity: registration.composition_authority.clone(),
capabilities: registration.capabilities.clone(), // from the registration bundle
metadata: HashMap::new(), // fresh per request
deadline: Some(Instant::now() + self.default_timeout), // root deadline (W7)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // from the bundle, empty for leaves
// Per-call env composition (ADR-024): the root env is a composite
// of the curated base + this connection's imported-ops overlay +
// the active session overlay (if any). The CallAdapter builds this
// composite per incoming call — same shape as per-call identity
// resolution via IdentityProvider. Handlers call env.invoke();
// the composite routes to the right overlay.
env: self.compose_root_env(/* connection, session */),
abort_policy: AbortPolicy::default(), // abort-dependents (ADR-016 Decision 6)
internal: false, // external call — ACL against caller identity
}
}
```
The `internal: false` here is what makes a wire call a wire call — ACL checks against the caller's resolved `identity`. When a handler subsequently calls `context.env.invoke(...)`, the `OperationEnv::invoke()` path (see [operation-registry.md](operation-registry.md#operationenv)) constructs a nested `OperationContext` with `internal: true`, switching authority to `handler_identity`. The two construction paths — `CallAdapter` for wire-originated, `OperationEnv::invoke()` for composition-originated — are the only places `internal` is set. Handlers cannot set it themselves (the field is module-private for writes — see [operation-registry.md](operation-registry.md#operationcontext) and ADR-015).
The per-call `env` composition (ADR-024) is the operation-dispatch analogue of the per-call identity resolution the CallAdapter already does via `IdentityProvider`. Both are integration-point patterns: the trait object owns the routing, the CallAdapter supplies the right sources per call. A connection's imported-ops overlay is part of the root env only for calls arriving on that connection; a session overlay is part of the root env only when a session is active. See ADR-024.
### ResponseEnvelope
The universal return type from all operation invocations:
```rust
pub struct ResponseEnvelope {
pub request_id: String,
pub result: Result<Value, CallError>,
}
pub struct CallError {
pub code: String, // protocol-level (NOT_FOUND, FORBIDDEN, ...) or operation-level (ADR-023)
pub message: String, // human-readable, for logging — not for programmatic handling
pub retryable: bool,
pub details: Option<Value>, // typed error payload, conforms to ErrorDefinition.schema (ADR-023)
}
```
Local dispatch produces `ResponseEnvelope` with no serialization overhead. The `CallAdapter` converts `ResponseEnvelope` to `EventEnvelope` for the wire. When a handler returns a `CallError` whose `code` matches a declared `ErrorDefinition`, the `details` field carries the typed error payload. See ADR-023.
### Connection and Stream Lifecycle
**Connection drop**: When the QUIC connection closes, all pending requests in the `PendingRequestMap` are failed with `call.error` code `INTERNAL` and message `"connection closed"`. All subscription channels are closed. The `CallAdapter::handle()` method returns `Ok(())` (clean shutdown) or `Err(HandlerError::ConnectionClosed)` (unexpected).
**Stream reset**: When a QUIC stream is reset mid-operation, the `FrameFramedReader` returns an error. If the stream was carrying a subscription, the `PendingRequestMap` entry is removed and the mpsc channel is closed. If the stream was carrying a call, the oneshot is resolved with an error. No `call.aborted` is sent — the stream is gone.
**Timeouts**: Default timeout for wire calls is 30 seconds, configurable via
`CallAdapter::with_timeout()`. The `build_root_context` sets
`OperationContext.deadline` to `now + default_timeout`. Composed calls
inherit the parent's deadline (children do **not** get a fresh 30s — the
root call's deadline bounds the entire call tree, preventing a depth-5
composition from running 150s). A composed call that exceeds the deadline
is cancelled (future dropped, `Drop` guards release resources) and returns
`CallError { code: "TIMEOUT", retryable: true }`. Subscriptions default to
no deadline (`deadline: None` — unbounded); the client can specify a
timeout in the `call.requested` payload. The `PendingRequestMap` sweeper
runs every 10 seconds and removes expired wire entries.
**Error handling in `CallAdapter::handle()`**: If a handler panics, the stream is closed and the `PendingRequestMap` entry (if any) is cleaned up by the next sweeper pass. Other streams and the connection are unaffected.
### Abort Cascade and Nested Calls
When a handler composes other operations via `OperationEnv::invoke()`, it creates a call tree: a parent request (r1) spawns children (r1-a, r1-b), which may spawn their own children. The `parent_request_id` field on `OperationContext` records this tree — it is the agency chain (ADR-015).
When `call.aborted` arrives for a parent request, the protocol cascades the abort to all non-terminal descendants in the tree. The CallAdapter walks the tree (indexed by `parent_request_id` in `PendingRequestMap`) and sends `call.aborted` for each descendant. The default policy is **`abort-dependents`**: aborting a request aborts everything downstream, regardless of branch. This is the correct default because aborted parent work has no consumer waiting for results — continuing is wasted work at best and unwanted side effects at worst (e.g., a `bash/exec` that keeps running after the caller stopped caring).
An opt-in **`continue-running`** policy is available for cases where long-running work should survive a parent's abort (e.g., a subscription that should keep streaming). Under `continue-running`, descendants that have already started continue to completion; descendants that haven't started yet are aborted; no new descendants start.
The abort policy 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 (the wire caller doesn't know the composition tree). The root context gets the default (`abort-dependents`); a handler can opt a child into `continue-running` at `invoke()` time. See ADR-016 Decision 6.
Handlers clean up resources when their call is cancelled (in Rust, the future is dropped and `Drop` guards release resources — HTTP streams, file handles, locks). This is a handler-level concern; the protocol's job is to cascade the abort. See ADR-016.
## Constraints
- The call protocol does not depend on any database. `PendingRequestMap` is in-memory. Durable session storage is a consumer concern.
- Operation specs use JSON Schema. The envelope is always JSON. Binary payloads may be base64-encoded in the `payload` field.
- Batch is not a protocol primitive — multiple `call.requested` events with correlated IDs provide equivalent semantics. See OQ-14.
- The call protocol is transport-agnostic at the envelope level. The `EventEnvelope` framing can run over QUIC streams, WebSocket frames, or Worker `postMessage`. The `CallAdapter` is the QUIC-specific implementation.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer. See ADR-005 and OQ-13.
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials, raw tokens) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. The wire format carries `serde_json::Value` and cannot enforce this at the type level — the constraint is architectural, enforced by the operation registry and by convention. Operations that need to share public key material use a dedicated operation that returns only the public component. See ADR-014.
- **Abort cascades to descendants.** `call.aborted` for a parent request cascades to all non-terminal descendants in the call tree. Default policy is `abort-dependents`; `continue-running` is an opt-in. See ADR-016.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc as call protocol foundation | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc provides framing and service dispatch |
| Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| ALPN per connection | [ADR-006](../../decisions/006-alpn-convention-and-connection-model.md) | `alknet/call` is a distinct ALPN, one connection per ALPN |
| ProtocolHandler receives Connection | [ADR-007](../../decisions/007-bistream-type-definition.md) | CallAdapter gets Connection, can accept/open multiple streams |
| Vault integration point | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
| Secret material flow | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Call protocol carries no secret material; capabilities injected at assembly layer |
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; handler identity + scoped env |
| Abort cascade for nested calls | [ADR-016](../../decisions/016-abort-cascade-for-nested-calls.md) | `call.aborted` cascades to descendants; default `abort-dependents`, `continue-running` opt-in |
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction |
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details` |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on global registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait.
## References
- [operation-registry.md](operation-registry.md) — OperationSpec, Handler, AccessControl, service discovery
- ADR-005: irpc as call protocol foundation
- ADR-012: Call protocol stream model
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -0,0 +1,680 @@
---
status: draft
last_updated: 2026-06-23
---
# Operation Registry
OperationSpec, Handler, OperationRegistry, AccessControl, service discovery, and irpc integration.
## What
The operation registry maps operation names to specs and handlers. It is the dispatch core of the call protocol — when a `call.requested` event arrives, the registry looks up the operation by name, checks access control, invokes the handler, and returns the result.
The registry is **layered by trust boundary** (ADR-024): a static, immutable curated layer (`Local` provenance, registered at startup) plus dynamic overlays for session ops (`Session` provenance, per-session) and imported ops (`FromCall` etc., per-connection). The immutability claim that previously applied to the whole registry is now scoped to the curated layer — see ADR-024 for the layering model and the rationale for why immutability is the security control for composing ops but not for imported leaves.
## Why
The operation registry provides:
- **Discoverability**: Clients can query `/services/list` and `/services/schema` to learn what operations exist before calling them
- **Access control**: Each operation declares its required scopes and resources; the registry enforces ACL before invoking the handler
- **Type safety**: JSON Schema for input and output enables validation and client code generation
- **Composability**: Handlers can invoke other operations through `OperationEnv` (local dispatch — remote dispatch is a separate architectural concern, see Constraints)
The registry design is informed by the `@alkdev/operations` TypeScript package, which demonstrated the same capabilities in JavaScript runtimes. The Rust implementation in alknet-call is canonical — it preserves the behavioral contract (namespace + operation name → invoke with input, return output) while defining the adapter contract (from_*, to_*) in Rust (see ADR-013).
## Architecture
### OperationSpec
Every registered operation has a spec that declares its name, type, schemas, and access control:
```rust
pub struct OperationSpec {
pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash)
pub namespace: String, // e.g., "fs", "agent"
pub op_type: OperationType, // Query, Mutation, Subscription
pub visibility: Visibility, // External (wire-callable) or Internal (composition-only)
pub input_schema: Value, // JSON Schema for input
pub output_schema: Value, // JSON Schema for output
pub error_schemas: Vec<ErrorDefinition>, // Declared domain errors (ADR-023)
pub access_control: AccessControl,
}
pub enum OperationType {
Query, // Read-only, idempotent (e.g., "fs/readFile", "services/list")
Mutation, // Side effects (e.g., "bash/exec", "github/authenticate")
Subscription, // Streaming (e.g., "agent/chat", "events/subscribe")
}
pub enum Visibility {
External, // Callable from the wire (call.requested from a client)
Internal, // Composition-only (env.invoke from a handler)
}
/// A declared operation-level error. See ADR-023.
pub struct ErrorDefinition {
pub code: String, // e.g., "FILE_NOT_FOUND", "RATE_LIMITED"
pub description: String, // Human-readable description
pub schema: Value, // JSON Schema for the error detail payload
pub http_status: Option<u16>, // HTTP status for adapter projection (from_openapi/to_openapi)
}
```
Operation names use slash-based paths without a leading slash, aligned with URL path conventions: `fs/readFile`, `agent/chat`, `services/list`. The leading slash is added when needed for display (`spec.path()` returns `/fs/readFile`) and for wire format (the `call.requested` payload uses `/fs/readFile`). See OQ-13 for the path format decision (single-node `service/op` vs head/worker `node/service/op`).
The `namespace` field is derived from the name: for `fs/readFile` it's `fs`, for `agent/chat` it's `agent`. It's a convenience accessor for ACL matching and service grouping.
Visibility (ADR-015) controls whether an operation is callable from the wire. `External` operations are wire-facing — they appear in `services/list` and accept `call.requested` from clients. `Internal` operations are composition-only — they return `NOT_FOUND` (not `FORBIDDEN`) when called from the wire, and do not appear in `services/list`. The assembly layer declares visibility at registration. All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call`) register operations as `Internal` by default (they're composition material, not directly callable); the handler that composes them is `External`.
### AccessControl
```rust
pub struct AccessControl {
pub required_scopes: Vec<String>, // AND-checked: caller must have ALL
pub required_scopes_any: Option<Vec<String>>, // OR-checked: caller must have at LEAST ONE
pub resource_type: Option<String>, // e.g., "service"
pub resource_action: Option<String>, // e.g., "read"
}
```
When a `call.requested` event arrives:
1. The `CallAdapter` resolves the caller's `Identity` from `AuthContext` (and possibly an `AuthToken` in the payload)
2. The registry checks operation **visibility** — if the operation is `Internal`, returns `call.error` with code `NOT_FOUND` (does not leak existence)
3. The registry checks `access_control.check(identity)` — for external calls (`internal: false`), ACL runs against the **caller's identity**; for internal calls (`internal: true`), ACL runs against the **handler's identity** (ADR-015)
4. If access is denied, the adapter returns `call.error` with code `FORBIDDEN`
5. If the relevant identity is `None` and the operation has restrictions, the adapter returns `call.error` with code `FORBIDDEN` and message `"authentication required"`
Operations with empty `AccessControl` (no required scopes, no resource checks) are accessible to all callers, including unauthenticated ones.
**Internal calls and authority context**: When a handler invokes another operation through `OperationEnv`, the nested call is marked `internal: true`, meaning it originated from composition (not from a wire request). The `internal` flag switches the authority context: the ACL check runs against the composing handler's `handler_identity` (set at registration), not the caller's identity and not as a blanket skip. This prevents privilege escalation through composition — a handler can only compose operations its own identity is authorized for. See ADR-015.
### Handler
```rust
pub type Handler = Arc<dyn Fn(Value, OperationContext) -> Pin<Box<dyn Future<Output = ResponseEnvelope> + Send>> + Send + Sync>;
```
Handlers are async — many operations (file I/O, HTTP service calls, irpc service calls) are inherently asynchronous. The handler receives an `async` runtime context and returns a `Future<Output = ResponseEnvelope>`.
A handler receives:
- `input: Value` — the deserialized `payload` from the `call.requested` event (always `serde_json::Value`)
- `context: OperationContext` — request ID, identity, metadata, env
And returns a `ResponseEnvelope` containing the result or an error. `ResponseEnvelope` is defined in [call-protocol.md](call-protocol.md#responseenvelope) — it carries the request ID and a `Result<Value, CallError>`. Local dispatch produces it with no serialization overhead; the `CallAdapter` converts it to `EventEnvelope` for the wire.
When a handler returns an error, the `CallError.code` is matched against the operation's declared `error_schemas` (ADR-023). If the code matches a declared `ErrorDefinition`, the `call.error` event carries that code and the error's detail payload. If it doesn't match, the `call.error` carries `INTERNAL`. This is how handler failures become typed errors on the wire instead of string-matched messages.
### OperationContext
```rust
pub struct OperationContext {
pub request_id: String,
pub parent_request_id: Option<String>,
pub identity: Option<Identity>, // Caller's identity (inbound — who invoked me)
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority (ADR-022)
pub capabilities: Capabilities,
pub metadata: HashMap<String, Value>,
/// Reachability set — the operations this handler may compose.
/// Populated from the registration bundle's `scoped_env` (ADR-022).
/// The reachability check in `OperationEnv::invoke()` consults
/// `scoped_env.allows(&name)`. This is data, not a dispatch trait.
pub scoped_env: ScopedOperationEnv,
/// Composition dispatch trait. A handler calls `env.invoke(...)` to
/// compose child operations. This is `Arc<dyn OperationEnv>` (a trait
/// object), not a concrete struct — the trait-object design is what
/// enables registry layering (ADR-024): the CallAdapter composes the
/// root env per call from the active layers (curated base + connection
/// overlay + session overlay), and session/connection overlays wrap
/// the base via trait layering. Same pattern as `IdentityProvider`
/// (ADR-004). See ADR-024.
pub env: Arc<dyn OperationEnv + Send + Sync>,
/// Abort policy for this call's descendants (ADR-016 Decision 6).
/// Default `AbortDependents` — aborting this request aborts all
/// non-terminal descendants. `ContinueRunning` is an opt-in for
/// long-running work that should survive a parent's abort. Set by the
/// composing handler via `OperationEnv::invoke()` (or
/// `invoke_with_policy()`), not by the wire caller.
pub abort_policy: AbortPolicy,
/// Deadline for this call and all descendants. Set by `build_root_context`
/// to `now + CallAdapter.default_timeout` (default 30s). Composed calls
/// inherit the parent's deadline (children do not get a fresh 30s — the
/// root call's deadline bounds the entire call tree). A composed call
/// that exceeds the deadline is cancelled (future dropped, `Drop` guards
/// release resources). `None` means no deadline (unbounded — used for
/// long-running subscriptions). See call-protocol.md → Timeouts.
pub deadline: Option<Instant>,
/// Composition-origin flag. Set by `OperationEnv::invoke()` (true) or the
/// `CallAdapter` dispatch path (false) — never by handlers. Module-private
/// for writes; read via `is_internal()`. See ADR-015.
pub(crate) internal: bool,
}
/// Abort cascade policy for a call's descendants (ADR-016).
///
/// `AbortDependents` (default): aborting this call cascades to all
/// non-terminal descendants.
///
/// `ContinueRunning` (opt-in): descendants that have already started
/// continue to completion; descendants that haven't started are aborted;
/// no new descendants start.
pub enum AbortPolicy {
AbortDependents,
ContinueRunning,
}
impl Default for AbortPolicy {
fn default() -> Self { Self::AbortDependents }
}
impl OperationContext {
pub fn is_internal(&self) -> bool { self.internal }
}
```
- `request_id`: Correlates with the `call.requested` event's `id` field
- `parent_request_id`: Set when this call was initiated by another operation (via `OperationEnv`). Records the agency chain — the call tree is the principal→agent chain (ADR-015)
- `identity`: The authenticated caller (from `IdentityProvider`) — inbound auth (who is calling me). For external calls, this is who sent the `call.requested`. For internal calls, this is the parent handler's `handler_identity` (propagated through `OperationEnv::invoke()`)
- `handler_identity`: The composition authority of the handler processing this call. `None` for leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose. `Some(...)` for `Local` and `Session` ops that can compose children. For internal calls (`internal: true`), the ACL check runs against this authority (ADR-015, ADR-022). This is NOT a peer `Identity` — it's a declared authority bundle set at registration by the assembly layer
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
- `metadata`: Request-scoped context (tracing IDs, connection info). **Must not hold secret material** — see ADR-014. **Does not propagate through `OperationEnv::invoke()`** — nested calls get fresh metadata. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`.
- `scoped_env`: The reachability set — the operations this handler may compose. Populated from the registration bundle's `scoped_env` (ADR-022). The reachability check in `OperationEnv::invoke()` consults `scoped_env.allows(&name)`. This is *data* (a `ScopedOperationEnv` struct), not a dispatch trait. `None`/empty for leaves.
- `env`: The composition dispatch trait (`Arc<dyn OperationEnv + Send + Sync>`). A handler calls `context.env.invoke(...)` to compose child operations. This is a trait object, not a concrete struct — the trait-object design enables registry layering (ADR-024): the CallAdapter composes the root env per call from the active layers (curated base + connection overlay + session overlay), and overlays wrap the base via trait layering. Same pattern as `IdentityProvider` (ADR-004). See ADR-024.
- `internal`: When `true`, this call originated from composition (a handler calling another operation via `OperationEnv`), not from a wire request. This switches the authority context: ACL runs against `handler_identity`, not `identity`. The `internal` field uses module-private construction — handlers construct `OperationContext` through `OperationEnv::invoke()` which sets `internal: true`, or through the `CallAdapter` dispatch path which sets `internal: false`. The field is not `pub` for writes; only `pub fn is_internal(&self) -> bool` is exposed for reads. See ADR-015.
`identity` and `capabilities` are orthogonal: identity is inbound (who is calling me), capabilities are outbound (what credentials I can use). `identity` and `handler_identity` are the principal/agent pair: `identity` is the principal (who delegated), `handler_identity` is the agent (who is acting). See ADR-014 for capabilities, ADR-015 for the privilege model, and ADR-022 for the composition authority type.
### OperationRegistry
```rust
pub struct OperationRegistry {
operations: HashMap<String, HandlerRegistration>,
}
```
The registry maps operation names to `HandlerRegistration` bundles. The curated layer (Layer 0) is a `HashMap<String, HandlerRegistration>`; session and connection overlays (Layers 1 and 2) are separate maps that the `CallAdapter` composes into the per-call `OperationContext.env` (ADR-024). See ADR-022 for the full registration model and ADR-024 for the layering model. Key methods:
- `register(registration)`: Add an operation to the curated layer at startup
- `registration(name)`: Find a registration by operation name (checks active overlays first, then curated base — ADR-024). Returns spec, handler, provenance, composition authority, scoped env, capabilities.
- `invoke(name, input, context)`: Look up, check ACL, invoke handler, return result
- `list_operations()`: Return all registered specs (for `/services/list` — returns curated + active overlay ops)
### Request ID Generation
Request IDs correlate `call.requested`/`call.responded` events and index the
abort-cascade tree (`PendingRequestMap` is keyed by request ID, ADR-016).
- **Wire calls**: the root `OperationContext.request_id` is the `id` field
from the wire `call.requested` event (generated by the client).
- **Composed calls**: `OperationEnv::invoke()` generates a new `request_id`
for each child via `generate_request_id()` — a UUID v4 (or
`parent_id + "-" + counter`). Deterministic IDs (e.g.
`format!("env-{name}")`) **must not** be used — they collide across
concurrent invocations of the same operation, corrupting
`PendingRequestMap` correlation and the abort-cascade tree.
- **Wire visibility**: composed child `request_id`s are **internal** — they
appear in `PendingRequestMap` 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. The
exception is `from_call` ops, which generate their own wire ID when
forwarding to the remote node (the remote node's `PendingRequestMap`
indexes it).
### HandlerRegistration
The registration bundle carries everything the dispatch path needs to construct an `OperationContext`. See ADR-022 for the full rationale.
```rust
pub struct HandlerRegistration {
pub spec: OperationSpec,
pub handler: Handler,
pub provenance: OperationProvenance,
pub composition_authority: Option<CompositionAuthority>, // None for leaves
pub scoped_env: Option<ScopedOperationEnv>, // None for leaves
pub capabilities: Capabilities,
}
```
#### OperationProvenance
Where the op came from. Determines composition capability, default
visibility, and trust model. See ADR-022 for rationale.
```rust
pub enum OperationProvenance {
Local, // Assembly-written, trusted, can compose
FromOpenAPI, // HTTP forwarding stub (from_openapi), leaf
FromMCP, // MCP forwarding stub (from_mcp), leaf
FromCall, // QUIC forwarding stub (from_call), leaf locally
FromJsonSchema, // JSON Schema definition, no handler — schema only
Session, // Agent-written, sandboxed, can compose within sandbox
}
```
| Provenance | Can compose? | Has composition authority? | Default visibility |
|-----------|-------------|---------------------------|-------------------|
| `Local` | Yes | Yes — scopes set by assembly layer | External or Internal (assembly declares) |
| `FromOpenAPI` | No (leaf) | No | Internal |
| `FromMCP` | No (leaf) | No | Internal |
| `FromCall` | No (leaf in local registry) | No | Internal |
| `FromJsonSchema` | N/A (no handler) | No | N/A |
| `Session` | Yes (within sandbox) | Yes — scopes set at sandbox creation | Internal always |
#### CompositionAuthority
The declared authority (label + scopes + resources) the handler operates
under when composing children. `None` for leaves. This replaces ADR-015's
`handler_identity: Identity` — it's not a peer identity, it's a declared
authority bundle. See ADR-022.
```rust
pub struct CompositionAuthority {
pub label: String, // e.g., "agent-chat" — not a peer id
pub scopes: Vec<String>, // e.g., ["llm:call", "fs:read"]
pub resources: HashMap<String, Vec<String>>, // e.g., {"service": ["vastai"]}
}
impl CompositionAuthority {
pub fn none() -> Option<Self> { None } // Convenience for leaves
pub fn new(label: &str, scopes: impl IntoIterator<Item = String>) -> Self { ... }
pub fn as_identity(&self) -> Option<Identity> { ... } // Synthetic Identity for ACL
}
```
- `provenance`: Determines composition capability. Only `Local` and `Session` ops can compose; leaves get `composition_authority: None` and `scoped_env: None`.
- `composition_authority`: The declared authority the handler operates under when composing children. `None` for leaves. See ADR-022.
- `scoped_env`: The set of operations this handler may reach via `env.invoke()`. `None` for leaves (empty env). The reachability control from ADR-015.
- `capabilities`: Outbound credentials (decrypted API keys, signing keys). Populated by the assembly layer from the vault at registration time. See [Capability Injection](#capability-injection).
The `OperationRegistryBuilder` provides a fluent API with convenience methods for common cases:
```rust
// with_local: Local provenance, full bundle — all 5 args required.
// with_local(spec, handler, composition_authority, scoped_env, capabilities)
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty authority, empty env, empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
// Agent handler (Local, composes — authority + scoped env + capabilities)
.with_local(agent_chat_spec(), Arc::new(agent_chat_handler),
CompositionAuthority::new("agent-chat", ["llm:call", "fs:read", "vastai:query"]),
ScopedOperationEnv::new(["fs/readFile", "vastai/listMachines", "llm/generate"]),
Capabilities::new().with_api_key("google", google_api_key))
// Imported ops (leaves — no authority, no scoped env; capabilities for outbound HTTP)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_handler), vastai_credentials)
.build();
```
The CLI binary (or assembly layer) constructs the registry and passes it to the `CallAdapter`. Once built, the **curated layer** (Layer 0 — `Local` provenance ops) is immutable. Session and imported overlays are dynamic at their respective scopes (per-session, per-connection) per ADR-024. The `CallAdapter` composes the root `OperationContext.env` per incoming call from the active layers.
### OperationEnv
The `OperationEnv` trait is the universal composition mechanism. A handler calls `context.env.invoke("fs", "readFile", input, &context)` and gets a `ResponseEnvelope` back — regardless of whether the operation runs locally, via an irpc service, or on a remote node.
```rust
/// The composition dispatch trait. A handler composes child operations
/// through its `OperationContext.env` (which implements this trait).
///
/// This must remain a trait, not a concrete type — session-scoped
/// registries (OQ-19) depend on wrapping the global env via trait
/// layering. Making `OperationEnv` concrete or hardcoding the global
/// registry into the dispatch path would close the session-overlay
/// pattern.
#[async_trait]
pub trait OperationEnv: Send + Sync {
/// Compose a child operation. The child's `OperationContext` is
/// constructed with `internal: true`, inheriting the parent's
/// composition authority as the child's caller identity. The abort
/// policy defaults to the parent's (ADR-016 Decision 6, W19).
///
/// Default impl: delegates to `invoke_with_policy` with
/// `parent.abort_policy.clone()`. Impls only need to implement
/// `invoke_with_policy` — `invoke` is provided.
async fn invoke(
&self,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
) -> ResponseEnvelope {
self.invoke_with_policy(namespace, operation, input, parent, parent.abort_policy.clone()).await
}
/// Compose a child with an explicit abort policy (ADR-016 Decision 6).
/// Use `AbortPolicy::ContinueRunning` for long-running work that
/// should survive a parent's abort. This is the required method —
/// `invoke()` delegates to it with the parent's policy.
async fn invoke_with_policy(
&self,
namespace: &str,
operation: &str,
input: Value,
parent: &OperationContext,
policy: AbortPolicy,
) -> ResponseEnvelope;
/// Does this env contain the named operation? Used by
/// `CompositeOperationEnv` to probe overlays before dispatching
/// (ADR-024). The composite checks `session.contains()` →
/// `connection.contains()` → base, dispatching to the first overlay
/// that contains the op. Default impl returns `true` (a single-layer
/// env like `LocalOperationEnv` contains everything it can dispatch).
fn contains(&self, name: &str) -> bool { true }
}
```
The `parent` parameter propagates the calling context: the nested call gets `parent_request_id: Some(parent.request_id)`, inherits `parent.handler_identity` as the caller identity, and is marked `internal: true`.
**Metadata does not propagate through composition.** Nested calls get fresh metadata (`HashMap::new()`), not the parent's metadata bag. This is a security constraint (ADR-014): `metadata: HashMap<String, Value>` accepts any `serde_json::Value`, including secret material. If metadata propagated through `env.invoke()`, a handler that accidentally placed a secret in metadata would leak it to every child operation — and if a child is a `from_call` operation (ADR-017), the metadata would cross the wire to the remote node. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`, not in ambient context.
**Local dispatch only.** The initial `OperationEnv` implementation for the
curated layer (Layer 0) dispatches directly through the local
`OperationRegistry`. The composite env (curated + session + connection
overlays) is a separate type built by the `CallAdapter` per call — see
ADR-024 and the `CompositeOperationEnv` sketch below.
```rust
/// Layer 0 dispatch — the curated registry. This is the base env that
/// overlays wrap. See ADR-024 for the layering model.
pub struct LocalOperationEnv {
registry: Arc<OperationRegistry>,
}
#[async_trait]
impl OperationEnv for LocalOperationEnv {
// `invoke` uses the default impl (delegates to `invoke_with_policy`
// with `parent.abort_policy.clone()`).
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check (ADR-015, ADR-022): is this op in the parent's
// scoped env? If not, return NOT_FOUND. This bounds the
// parameterized-dispatch attack surface — a handler (or an LLM
// picking tools) can only reach declared ops. The reachability set
// is on `parent.scoped_env` (data), not on `parent.env` (dispatch
// trait) — see ADR-024 for the split.
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
let registration = self.registry.registration(&name);
let context = OperationContext {
// Unique per invocation — a UUID v4 or parent_id + counter.
// A deterministic ID (e.g. format!("env-{name}")) collides across
// concurrent invocations of the same operation, which corrupts
// PendingRequestMap correlation and the abort-cascade tree
// (ADR-016), which is indexed by parent_request_id.
request_id: generate_request_id(),
parent_request_id: Some(parent.request_id.clone()),
// Parent's composition authority becomes the caller for the child.
// This is the authority switch: the child's ACL checks against
// the parent's authority, not the original wire caller's identity.
identity: parent.handler_identity.as_identity(),
// Child's own composition authority (from its registration).
// None for leaves — they don't compose, so this is never used
// for ACL on a grandchild.
handler_identity: registration.composition_authority.clone(),
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-014)
abort_policy: policy, // Explicit policy (from invoke() default or invoke_with_policy)
deadline: parent.deadline, // Inherit parent's deadline (children don't get a fresh 30s)
scoped_env: registration.scoped_env.clone()
.unwrap_or_else(ScopedOperationEnv::empty), // Child's own scoped env (empty for leaves)
// Dispatch trait: the child inherits the parent's env (the same
// composite of curated base + active overlays). See ADR-024.
env: parent.env.clone(),
internal: true, // Nested calls use handler authority
};
self.registry.invoke(&name, input, context).await
}
// `contains` uses the default impl (returns true — the curated registry
// contains everything it can dispatch). For a single-layer env, the
// reachability check in `invoke_with_policy` is the real gate.
}
```
The composite env (built by the `CallAdapter` per incoming call) wraps the
curated base and any active overlays:
```rust
/// Per-call composite env (ADR-024). Built by the CallAdapter in
/// build_root_context from the active layers. The child inherits this by
/// Arc::clone through invoke().
pub struct CompositeOperationEnv {
session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1 — active session, if any
connection: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 2 — this connection's imported ops
base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 — curated registry (LocalOperationEnv)
}
#[async_trait]
impl OperationEnv for CompositeOperationEnv {
// `invoke` uses the default impl (delegates to `invoke_with_policy`
// with `parent.abort_policy.clone()`).
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value, parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
let name = format!("{namespace}/{operation}");
// Reachability check against parent.scoped_env (same as LocalOperationEnv).
if !parent.scoped_env.allows(&name) {
return ResponseEnvelope::not_found(name);
}
// Dispatch in overlay order: session → connection → curated base.
// First overlay that *contains* the op wins. `contains()` (ADR-024)
// is the probe — it avoids the sentinel-return ambiguity and ensures
// cross-impl interop: any OperationEnv impl that correctly reports
// `contains` works with this composite.
if let Some(session) = &self.session {
if session.contains(&name) {
return session.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
if let Some(connection) = &self.connection {
if connection.contains(&name) {
return connection.invoke_with_policy(namespace, operation, input, parent, policy).await;
}
}
self.base.invoke_with_policy(namespace, operation, input, parent, policy).await
}
fn contains(&self, name: &str) -> bool {
// The composite contains the op if any layer does.
self.session.as_ref().map_or(false, |s| s.contains(name))
|| self.connection.as_ref().map_or(false, |c| c.contains(name))
|| self.base.contains(name)
}
}
```
The `contains()` method (review #003 C9) is the overlay-dispatch contract.
It replaces the previous "sentinel or contains check — two-way door" framing,
which was ambiguous enough to produce non-interoperable `OperationEnv` impls.
The structural decision (composite trait object, overlay order, `Arc::clone`
inheritance) is locked by ADR-024; the dispatch contract (`contains` probe
before `invoke_with_policy`) is now locked too.
Two things happen in `invoke()`:
1. **Reachability check**: before constructing the child context, `invoke()` checks whether the requested op is in the parent's scoped env. If not, `NOT_FOUND`. This is the reachability control — a handler can only compose declared ops.
2. **Authority propagation**: the child's `identity` is the parent's `handler_identity` (the parent's composition authority becomes the caller). The child's `handler_identity` is the child's own registration's `composition_authority` — so if the child itself composes further, its children inherit the child's authority. This is the principal/agent chain from ADR-015, now wired via ADR-022.
Future work may add irpc service dispatch and remote call protocol dispatch as additional backends. The handler-facing API stays the same.
**`OperationEnv` must remain a trait.** This is a constraint, not a suggestion. The trait-based design enables registry layering (ADR-024): the CallAdapter composes the root env per call from the curated base + active connection/session overlays, and overlays wrap the base via trait layering. Session-scoped registries (OQ-19) and connection-scoped remote imports (ADR-017 `from_call`) are both overlays on the same base, using the same mechanism. Making `OperationEnv` concrete or hardcoding the global registry into the dispatch path would close both the session-overlay and connection-overlay patterns. This is the same integration-point pattern as `IdentityProvider` (ADR-004). See OQ-19 and ADR-024.
### Service Discovery
Two built-in operations expose what the node offers:
| Operation name | Display path | Type | Description |
|---------------|-------------|------|-------------|
| `services/list` | `/services/list` | Query | List registered operation names and metadata |
| `services/schema` | `/services/schema` | Query | Get the `OperationSpec` for a specific operation |
These are read-only — no admin operations are exposed through the call protocol itself.
`services/list` only returns `External` operations to remote callers. `Internal` operations are not part of the wire-facing API surface — they're implementation details of composition. A remote client cannot enumerate the internal call tree. See ADR-015.
`services/list` returns:
```json
{
"operations": [
{ "name": "fs/readFile", "namespace": "fs", "op_type": "query" },
{ "name": "agent/chat", "namespace": "agent", "op_type": "subscription" },
{ "name": "events/subscribe", "namespace": "events", "op_type": "subscription" }
]
}
```
`services/schema` accepts `{ "name": "fs/readFile" }` (no leading slash —
registry form, same as `OperationSpec.name`) and returns the full
`OperationSpec` including input/output JSON Schemas and declared
`error_schemas` (ADR-023). The `CallAdapter` normalizes the leading slash
from wire `operationId`s before lookup, so `services/schema` accepts both
`fs/readFile` and `/fs/readFile`. This enables client code generation: a
client reading the schema can produce typed error enums instead of generic
error handling.
### irpc Integration
irpc and the operation registry serve different scopes:
| Layer | Mechanism | Serialization | Scope |
|-------|-----------|---------------|-------|
| Call protocol (external) | `EventEnvelope` over QUIC streams | JSON | Cross-language, cross-node |
| irpc services (internal) | `#[rpc_requests]` derive macro, `Service` trait | postcard (binary) | Rust-to-Rust, in-process or in-cluster |
irpc services are an internal dispatch mechanism — they are not directly exposed on the call protocol. alknet-call itself uses irpc for its call-protocol framing (ADR-005); the vault no longer uses irpc (ADR-025 — direct method calls on `VaultServiceHandle`). The vault is accessed by the assembly layer (CLI binary) at startup, not by handlers at call time. See ADR-008 and ADR-014.
If a handler internally uses an irpc-based service, the handler bridges the two: it receives JSON input from the call protocol, calls the irpc service in-process (postcard, type-safe), and serializes the result back to JSON for the call protocol response. This layering preserves irpc's type safety for internal calls while keeping the external interface cross-language.
### Operation Registration at Startup
The CLI binary (or assembly layer) constructs `HandlerRegistration` bundles with provenance, composition authority, scoped env, and capabilities (from the vault — see [Capability Injection](#capability-injection)), then registers them before starting the endpoint:
```rust
// Assembly layer: unlock vault, derive credentials
let vault = VaultServiceHandle::new();
vault.unlock(&mnemonic, passphrase.as_deref())?;
let google_api_key = vault.decrypt(&google_key_blob)?;
let github_signing_key = vault.derive_ed25519(PATHS::GITHUB_SIGNING)?;
let vastai_credentials = Capabilities::new().with_http_token("vastai", vastai_token);
// Register operations — vault operations are NOT registered here
let registry = OperationRegistryBuilder::new()
// Built-in service discovery (Local, no composition — empty caps)
.with_local(services_list_spec(), Arc::new(services_list_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
// Agent handler (Local, composes — full bundle via .with())
.with(HandlerRegistration {
spec: agent_chat_spec(),
handler: Arc::new(agent_chat_handler),
provenance: OperationProvenance::Local,
composition_authority: Some(CompositionAuthority::new(
"agent-chat", ["llm:call", "fs:read", "vastai:query"])),
scoped_env: Some(ScopedOperationEnv::new(
["fs/readFile", "vastai/listMachines", "llm/generate"])),
capabilities: Capabilities::new().with_api_key("google", google_api_key),
})
// Vastai ops (FromOpenAPI, leaves — no authority, no scoped env)
.with_leaf(vastai_listMachines_spec(), Arc::new(vastai_listMachines_handler),
vastai_credentials.clone())
.build();
let call_adapter = CallAdapter::new(Arc::new(registry), identity_provider);
// Agent deployment: let call_adapter = CallAdapter::new(...).with_session_source(source);
```
The vault is used at construction time to populate `capabilities` in the registration bundle, not registered as call protocol operations. The curated layer (Layer 0) is immutable after construction — adding a `Local` op requires restarting the process. Session and imported overlays are dynamic at their respective scopes (ADR-024). This is consistent with OQ-04 (scoped to the `HandlerRegistry` by ADR-024), ADR-008, ADR-014, and ADR-022.
### Capability Injection
Handlers that need outbound credentials (LLM provider API keys, signing keys, HTTP service tokens) receive them through the `Capabilities` type on `OperationContext`, not by calling vault operations over the wire and not from environment variables. This is the mechanism that ADR-008 described in prose ("derived keys and decrypted credentials are injected into operation contexts at the assembly layer") and that ADR-014 specifies as a one-way door. ADR-022 specifies the registration path: capabilities live on the `HandlerRegistration` bundle, and the dispatch path populates `OperationContext.capabilities` from the bundle at call time.
The flow is:
```
Assembly layer (CLI startup):
1. Unlock vault (local, mnemonic from secure prompt or file)
2. Derive / decrypt the credentials each handler needs
3. Construct HandlerRegistration bundles with capabilities from the vault
4. Register the bundles in the OperationRegistry
5. Start the endpoint
Handler invocation (at call time):
call.requested → CallAdapter looks up registration by op name
→ build_root_context populates OperationContext.capabilities from registration.capabilities
→ handler reads context.capabilities → uses the credential for its outbound call
```
The handler closure does **not** capture capabilities — that was the pre-ADR-022 "Model A" that created a circular dependency with per-request `OperationContext.capabilities`. Capabilities live on the registration bundle, and the dispatch path populates the context from the bundle. One model, one wiring path. See ADR-022 Decision 6.
The `Capabilities` type holds non-serializable, zeroized secret material. It does not implement `Serialize` — it cannot cross the call protocol wire even by accident. The concrete shape of the type (a typed map, a struct with named fields, a trait object) is a two-way door for implementation. The one-way constraints are fixed by ADR-014:
- Capabilities are populated by the assembly layer at registration (on the `HandlerRegistration` bundle). They are never populated from call protocol inputs.
- Capabilities hold secret material that does not implement `Serialize` and does not appear in `EventEnvelope` payloads.
- The call protocol carries no secret material. See [call-protocol.md](call-protocol.md) for the wire-level constraint.
- **Capabilities are `Clone` and cloned through composition.** `OperationEnv::invoke()` calls `parent.capabilities.clone()` to pass capabilities to nested calls. This is intentional: a child handler needs the same outbound credentials as its parent (e.g., the `/agent/chat` handler composing `/fs/readFile` may need the same API key for an outbound LLM call). The security implication is that each composition step duplicates the secret material reference — but capabilities are scoped (the handler can only use what the assembly layer declared on the registration bundle), and children run under the parent's composition authority (ADR-015, ADR-022). A clone is the same scoped handle, not a widening of scope. The concrete cloning semantics (reference-counted `Arc` vs deep copy of zeroized material) is a two-way door for implementation, but `Capabilities: Clone` is required by the composition model.
- **Capabilities must be immutable after construction.** No interior mutability, no `Mutex<Map>`, no `RefCell`. This makes the clone-semantics two-way door genuinely two-way: Arc-based clone (shared immutable state) and deep-copy clone (isolated state) are behaviorally identical when neither supports mutation. Without this guard, a handler that mutates capabilities (e.g., adds a derived key for a child) would make the mutation visible to siblings and the parent under Arc-based clone — shared mutable state across the call tree, a security-relevant behavior. Once shipped, handlers may depend on shared mutation, and switching from Arc-shared to deep-copy-isolated later is a behavior change that breaks them. The immutability guard prevents the "two-way door" from becoming a future one-way door.
**No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). A handler that needs a child key for a specific operation (e.g., signing for GitHub auth) receives a scoped capability that performs the derivation in-process — it never holds the master seed and never calls a network-exposed vault operation.
**Adapters take credential sources.** All import adapters (`from_openapi`, `from_mcp`, `from_jsonschema`, `from_call` — see ADR-017, constrained by ADR-014) register HTTP-backed, MCP-backed, or remote-call-backed operations. The credential each service needs (bearer token, API key, TLS identity for the remote connection) is provided by the assembly layer at registration time — the adapter receives a credential source, not a static token string. This is the integration point where the vault feeds credentials into backed operations, including LLM providers that expose OpenAPI-compatible endpoints. Adapter-registered operations are `Internal` by default (ADR-015) — they're composition material, not directly callable from the wire.
**`from_call` imports remote operations.** The `from_call` adapter (ADR-017) discovers operations on a remote call protocol endpoint via `services/list` and `services/schema`, then registers them with handlers that forward calls over the QUIC connection. This makes cross-node composition transparent — a handler calling `env.invoke("worker", "exec", ...)` doesn't know whether the operation is local or remote. Connection direction (who opened the QUIC connection) is independent of call direction (who calls whom) — both sides can call each other once connected.
**`from_call` trust is transitive.** A `from_call`-imported operation executes the remote node's code, not yours. The scoped env (ADR-015) bounds *which* operations are reachable, but not *what* they do. A compromised remote node can do anything its operations are declared to do (and anything its handler bugs allow). This is inherent to remote composition — same as trusting any RPC endpoint — but it must be explicit in the threat model. `from_call` means "I trust the remote node as much as my own handlers." The scoping protects the caller from reaching arbitrary ops; it does not protect against what the reached op does.
**Scoped composition env.** The `OperationEnv` given to a handler is scoped — it can only invoke a declared set of operations, set at registration on the `HandlerRegistration` bundle by the assembly layer (ADR-022). This bounds the parameterized-dispatch attack surface: a handler (or an LLM picking tools, or a quickjs sandbox) can only reach declared operations, not the entire registry. The scoped env is the reachability control; the composition authority is the authority control. Both are needed for least privilege. See ADR-015 and ADR-022.
## Constraints
- The registry is **layered by trust boundary** (ADR-024). The curated layer (`Local` provenance) is immutable after construction — adding a `Local` op requires restarting the process, which re-enters the startup trust boundary. Session (`Session`) and imported (`FromCall` etc.) ops are dynamic at their respective scopes (per-session, per-connection). The pre-ADR-024 blanket immutability claim was inherited by analogy from ADR-010's `HandlerRegistry` (ALPN-level) and did not apply to the operation registry — the TLS-config argument that justifies `HandlerRegistry` immutability does not touch the operation registry, which lives behind the single ALPN `alknet/call`.
- Operation specs use JSON Schema. The call protocol's external interface is always JSON. irpc's postcard serialization is internal only.
- `OperationEnv::invoke()` dispatches through the local registry. Remote dispatch (federation, head/worker routing) would be a separate mechanism at a different layer — not a prefix added to operation paths. irpc service dispatch is contracted but not built.
- The call protocol does not depend on any database. Operation specs are in-memory, populated at startup.
- `OperationContext.internal` is set by `OperationEnv`, not by callers. A handler cannot mark its own call as internal. The `internal` flag switches authority context (composition authority for ACL), it does not skip ACL — see ADR-015, ADR-022.
- **Operations have External/Internal visibility.** `Internal` operations return `NOT_FOUND` when called from the wire and are excluded from `services/list`. The assembly layer declares visibility at registration. See ADR-015.
- **The composition env is scoped.** A handler can only invoke operations declared in its scoped env (on the `HandlerRegistration` bundle). This bounds parameterized-dispatch attack surface. See ADR-015, ADR-022.
- **No vault operations are registered in the call protocol.** The vault is assembly-layer only (ADR-008, ADR-014). Handlers receive secret material through `OperationContext.capabilities`, not by calling vault operations over the wire.
- **The call protocol carries no secret material.** Secret material (private keys, API keys, mnemonics, decrypted credentials) must not appear in `call.requested` payloads, `call.responded` payloads, or `OperationContext.metadata`. See ADR-014.
- **Metadata does not propagate through composition.** `OperationEnv::invoke()` constructs fresh metadata for nested calls (`HashMap::new()`), not the parent's metadata. This prevents a handler that accidentally places a secret in metadata from leaking it to child operations — and if a child is a `from_call` operation (ADR-017), across the wire to a remote node. The tracing link is `parent_request_id`, not metadata propagation. See ADR-014.
- **Provenance determines composition capability.** Only `Local` and `Session` ops can compose. Leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) get `composition_authority: None` and `scoped_env: None` — they don't compose, so they don't need authority or reachability bounds. See ADR-022.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| irpc as call protocol foundation | [ADR-005](../../decisions/005-irpc-as-call-protocol-foundation.md) | irpc provides framing and service dispatch |
| Call protocol stream model | [ADR-012](../../decisions/012-call-protocol-stream-model.md) | Bidirectional streams, EventEnvelope, ID-based correlation |
| Static handler registration | [ADR-010](../../decisions/010-alpn-router-and-endpoint.md) | `HandlerRegistry` (ALPN-level) immutable after construction; `OperationRegistry` layered by ADR-024 (curated immutable, session/imported dynamic) |
| Vault integration via assembly layer | [ADR-008](../../decisions/008-secret-service-integration.md) | Vault is a capability source, accessed at assembly time |
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | Capabilities carry outbound credentials; call protocol carries no secret material |
| Privilege model and authority context | [ADR-015](../../decisions/015-privilege-model-and-authority-context.md) | `internal` = authority switch not ACL skip; External/Internal visibility; composition authority + scoped env |
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | Registration bundle carries provenance, composition authority, scoped env, capabilities; dispatch path reads from bundle |
| Operation registry layering | [ADR-024](../../decisions/024-operation-registry-layering.md) | Curated (static, immutable) + session and connection overlays (dynamic); `OperationEnv` as trait-object integration point; `OperationContext.env` split into `scoped_env` (data) and `env` (dispatch trait) |
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-13** (resolved): Operation path format is `/{service}/{op}`. Remote dispatch is a separate mechanism, not a path prefix.
- **OQ-14** (resolved): Batch is a client-side pattern of correlated `call.requested` events, not a protocol primitive.
- **OQ-16** (resolved by ADR-014): No vault operations are exposed over the call protocol for now.
- **OQ-19** (resolved): Session-scoped operation registries — agent-written operations overlaid on the curated registry via `OperationEnv` trait layering. Protocol doesn't need changes; `OperationEnv` must remain a trait. Session ops are `Session` provenance (ADR-022) — always `Internal`, compose under restricted authority scoped down at sandbox creation. Generalized by ADR-024 to cover connection-scoped overlays as well.
## References
- [call-protocol.md](call-protocol.md) — CallAdapter, EventEnvelope, stream model, PendingRequestMap
- ADR-005: irpc as call protocol foundation
- ADR-008: Vault integration point
- ADR-010: ALPN router and endpoint (static registration — applies to the `HandlerRegistry`, not the `OperationRegistry`; see ADR-024 for the distinction)
- ADR-012: Call protocol stream model
- ADR-024: Operation registry layering (curated + session/connection overlays; `OperationEnv` as trait-object integration point)
- Reference implementation: `/workspace/@alkdev/alknet-main/crates/alknet-core/src/call/`

View File

@@ -0,0 +1,48 @@
---
status: draft
last_updated: 2026-06-22-21
---
# alknet-core
Core library for ALPN-based protocol dispatch. Every handler crate depends on alknet-core.
## Documents
| Document | Status | Description |
|----------|--------|-------------|
| [core-types.md](core-types.md) | draft | ProtocolHandler trait, HandlerError, Connection, BiStream, StreamError |
| [endpoint.md](endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, graceful shutdown |
| [auth.md](auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow |
| [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle |
## Applicable ADRs
| ADR | Title | Relevance |
|-----|-------|-----------|
| [001](../../decisions/001-alpn-protocol-dispatch.md) | ALPN-Based Protocol Dispatch | Core architectural model |
| [002](../../decisions/002-protocol-handler-trait.md) | ProtocolHandler Trait | The trait every handler implements |
| [003](../../decisions/003-crate-decomposition.md) | Crate Decomposition | alknet-core's position in the crate graph |
| [004](../../decisions/004-auth-as-shared-core.md) | Auth as Shared Core | IdentityProvider in core |
| [006](../../decisions/006-alpn-convention-and-connection-model.md) | ALPN String Convention | ALPN format, one-ALPN-per-connection |
| [007](../../decisions/007-bistream-type-definition.md) | BiStream Type Definition | Connection, BiStream trait, SendStream, RecvStream |
| [009](../../decisions/009-one-way-door-decision-framework.md) | One-Way Door Framework | Decision classification |
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Endpoint, HandlerRegistry, accept loop |
| [011](../../decisions/011-authcontext-structure.md) | AuthContext Structure | AuthContext fields and resolution flow |
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Per-request identity on OperationContext; admin scope for config reload |
## Relevant Open Questions
| OQ | Title | Status | Relevance |
|----|-------|--------|-----------|
| OQ-04 | Dynamic handler registration | resolved (start static) | HandlerRegistry is immutable at startup |
| OQ-05 | Multi-connectivity endpoint | resolved (quinn + iroh) | AlknetEndpoint supports both, both feature-gated |
| OQ-11 | Handler-level auth resolution observability | resolved | Handlers store resolved identity on Connection; two identity scopes (connection-level for observability, per-request for ACL) |
## Key Design Principles
1. **One trait, one dispatch point**: `ProtocolHandler` is the only abstraction handlers implement. No StreamInterface/MessageInterface split.
2. **ALPN does the routing**: The endpoint dispatches by ALPN string. No byte-peeking, no ListenerConfig enum.
3. **Handlers own their wire format**: Each handler manages its own protocol parsing. alknet-core provides the Connection, not the framing.
4. **Auth is hybrid**: The endpoint provides what it can (TLS-level auth). Handlers complete what they need. AuthContext may be partial.
5. **WASM door preserved**: BiStream is a trait, Connection is an opaque type. Core types don't assume tokio or quinn in public APIs.

View File

@@ -0,0 +1,263 @@
---
status: draft
last_updated: 2026-06-21
---
# Authentication
AuthContext, Identity, IdentityProvider, AuthToken, and the resolution flow.
See [ADR-004](../../decisions/004-auth-as-shared-core.md) and [ADR-011](../../decisions/011-authcontext-structure.md) for rationale.
## AuthContext
Created by the endpoint for each incoming connection. Passed to `ProtocolHandler::handle()` as an immutable reference.
```rust
#[derive(Clone)]
pub struct AuthContext {
/// The peer's authenticated identity, if resolved by the endpoint.
/// None means the endpoint has no identity information for this connection.
pub identity: Option<Identity>,
/// The negotiated ALPN for this connection. Always present.
pub alpn: Vec<u8>,
/// The peer's remote address, if available. Informational (NAT/proxy).
pub remote_addr: Option<SocketAddr>,
/// SHA-256 fingerprint of the TLS client certificate, if presented.
/// Set by the endpoint during TLS handshake. Handlers may use this for
/// fingerprint-based auth even when IdentityProvider returns None.
pub tls_client_fingerprint: Option<String>,
}
```
### Construction by the endpoint
The endpoint constructs `AuthContext` from the QUIC connection:
1. `alpn`: From `connection.alpn()` — always present after TLS handshake.
2. `remote_addr`: From `connection.remote_addr()` — may be `None` for iroh connections.
3. `tls_client_fingerprint`: Extracted from the TLS session's client certificate, if one was presented.
4. `identity`: If a TLS client fingerprint is available, the endpoint calls `IdentityProvider::resolve_from_fingerprint()`. If it resolves, `identity = Some(resolved)`. If not, `identity = None`.
### Handler-level resolution
Handlers that require authentication extract protocol-specific credentials and call `IdentityProvider` inside `handle()`. When identity is resolved, the handler stores it on the `Connection` for observability:
```rust
// Example: CallAdapter extracting an AuthToken from the first frame
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
let identity = match &auth.identity {
Some(id) => id.clone(), // Endpoint already resolved identity
None => {
let stream = connection.accept_bi().await?;
let token = extract_auth_token(stream).await?;
self.identity_provider
.resolve_from_token(&token)
.ok_or(HandlerError::AuthRequired)?
}
};
connection.set_identity(identity); // Store for observability (OQ-11)
// ... proceed with authenticated identity
}
```
Handlers that don't require authentication (e.g., DNS resolver, health check) can ignore `auth.identity` entirely and don't call `set_identity`.
### Two Identity Scopes
There are two distinct identity scopes that must not be conflated:
| Scope | Where it's set | Where it's stored | What it represents | Used for |
|-------|---------------|-------------------|-------------------|----------|
| Connection-level | Handler in `handle()` | `Connection` (via `set_identity`) | Who opened this QUIC connection | Observability, logging, audit |
| Per-request | `CallAdapter` per `call.requested` | `OperationContext.identity` | Who is making this specific call | ACL (ADR-015) |
The connection-level identity is stable — set once when the handler resolves it. The per-request identity is dynamic — resolved per `call.requested`, potentially different across requests on the same connection (if different auth tokens are used). The per-request identity takes precedence for ACL on `OperationContext`; the connection-level identity is for observability only, not for ACL.
`Connection` exposes `set_identity` via interior mutability — the handler sets it once when resolved, the endpoint and observability layers read it. The identity is write-once-read-many.
### AuthContext is Clone and immutable
- `derive(Clone)` allows handlers to clone `AuthContext` for per-stream or per-channel contexts.
- `handle()` receives `&AuthContext` — immutable. Handlers that resolve identity create local variables, they don't mutate the shared context. This prevents cross-contamination between streams on the same connection.
## Identity
The authenticated peer identity. Carries authorization information.
```rust
#[derive(Debug, Clone, PartialEq)]
pub struct Identity {
/// Unique identifier string. Fingerprint, key prefix, or principal name.
pub id: String,
/// Authorization scopes. e.g., ["relay:connect", "secrets:derive"]
pub scopes: Vec<String>,
/// Named resource lists. e.g., {"service": ["gitea", "registry"]}
pub resources: HashMap<String, Vec<String>>,
}
```
This is the same structure as the reference implementation (`alknet-main/crates/alknet-core/src/auth/identity.rs`), minus the russh dependency. The `id` field is ALPN-agnostic:
- SSH key auth: `"SHA256:abc123..."` (key fingerprint)
- API key auth: `"alk_test"` (key prefix)
- Certificate auth: `"username"` (principal name)
## AuthToken
Opaque authentication token carried in protocol frames.
```rust
#[derive(Debug, Clone)]
pub struct AuthToken {
pub raw: Vec<u8>,
}
```
Unchanged from the reference implementation. The handler that extracted it knows its encoding (UTF-8 string, binary token, etc.).
## IdentityProvider
Trait for resolving credentials to identities. Implemented by `ConfigIdentityProvider`.
```rust
pub trait IdentityProvider: Send + Sync + 'static {
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
```
- `resolve_from_fingerprint()`: Used by the endpoint (TLS client cert) and by SSH (key fingerprint).
- `resolve_from_token()`: Used by call protocol (AuthToken in first frame) and HTTP (Bearer header).
Both methods return `Option<Identity>``None` means the credential is not recognized.
## ConfigIdentityProvider
The default implementation. Resolves identities from `DynamicConfig`:
```rust
pub struct ConfigIdentityProvider {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
```
The "Config" prefix indicates that identities are resolved from configuration (as opposed to a database or external service). This reads from `ArcSwap<DynamicConfig>`, which is hot-reloadable — not from `StaticConfig`. An alternative name would be `DynamicConfigIdentityProvider` to make this clearer, but `ConfigIdentityProvider` is consistent with the reference implementation and the naming is unlikely to cause confusion in practice.
How it resolves:
- **Fingerprint**: Look up in `DynamicConfig::auth::authorized_keys_fingerprints`. If found, return `Identity { id: fingerprint, scopes: ["relay:connect"], resources: {} }`.
- **Token**: Parse as UTF-8. If it starts with `alk_`, look up in `DynamicConfig::auth::api_keys` by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: entry.resources }`.
Changes to `DynamicConfig` via `ConfigReloadHandle` are reflected immediately — `ConfigIdentityProvider` reads from `ArcSwap` on every call.
## Resolution Flow
### Endpoint-level (before `handle()`)
```
QUIC connection arrives
→ TLS handshake (ALPN negotiation)
→ Extract TLS client certificate fingerprint (if presented)
→ If fingerprint present: IdentityProvider::resolve_from_fingerprint()
→ Some(identity): auth.identity = Some(identity)
→ None: auth.identity = None
→ Construct AuthContext { identity, alpn, remote_addr, tls_client_fingerprint }
→ Look up handler by alpn
→ tokio::spawn(handler.handle(connection, &auth))
```
### Handler-level (inside `handle()`)
```
Handler receives &AuthContext
→ If auth.identity is Some: use it (endpoint already resolved)
→ If auth.identity is None and handler requires auth:
→ Extract protocol-specific credential (AuthToken, SSH key, etc.)
→ Call IdentityProvider::resolve_from_token() or resolve_from_fingerprint()
→ If resolved: use the Identity
→ If not resolved: return HandlerError::AuthRequired
→ If handler doesn't require auth: proceed without identity
```
## IdentityProvider Injection
Handlers need access to `IdentityProvider` to resolve credentials inside `handle()`. Since `ProtocolHandler::handle()` doesn't receive an `IdentityProvider` parameter, each handler must obtain it through **constructor injection**:
```rust
// Example: SshAdapter holds an Arc<dyn IdentityProvider>
pub struct SshAdapter {
identity_provider: Arc<dyn IdentityProvider>,
// ... other handler-specific state
}
#[async_trait]
impl ProtocolHandler for SshAdapter {
fn alpn(&self) -> &'static [u8] { b"alknet/ssh" }
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> {
let identity = match &auth.identity {
Some(id) => id.clone(),
None => {
// Extract SSH key fingerprint, resolve via identity_provider
let fingerprint = extract_ssh_fingerprint(&connection).await?;
self.identity_provider
.resolve_from_fingerprint(&fingerprint)
.ok_or(HandlerError::AuthRequired)?
}
};
// ...
}
}
```
The CLI binary constructs each handler with `Arc::clone(&identity_provider)` and passes it when building the `HandlerRegistry`. This is the **assembly pattern**: the CLI (the only crate that depends on all handlers) wires dependencies together.
The endpoint's `AlknetEndpoint` also holds `Arc<dyn IdentityProvider>` for endpoint-level auth resolution (TLS client certificate fingerprints), but handlers don't receive it from the endpoint — they receive it at construction time from the CLI.
| Handler | Credential source | Resolution method |
|---------|------------------|-----------------|
| SshAdapter | SSH public key handshake | `resolve_from_fingerprint()` |
| CallAdapter | AuthToken in first frame | `resolve_from_token()` |
| HttpAdapter | `Authorization: Bearer` header | `resolve_from_token()` |
| DnsAdapter | AuthToken in query labels | `resolve_from_token()` |
| GitAdapter | Signed push certificate | `resolve_from_fingerprint()` |
| SftpAdapter | SSH key (shares with SshAdapter) | `resolve_from_fingerprint()` |
## Key Differences from Reference Implementation
| Aspect | Reference | New Model |
|--------|-----------|-----------|
| Auth resolution | Inside SSH handler, before `handle()` | Hybrid: endpoint resolves TLS-level, handler resolves protocol-level |
| AuthContext type | None (just `Arc<ArcSwap<DynamicConfig>>` + `IdentityProvider`) | Explicit struct with optional fields |
| `Identity.id` | Always a fingerprint or API key prefix | Same, but ALPN-agnostic documentation |
| `ConfigIdentityProvider` | Depends on russh for `PublicKey` types | No russh dependency; fingerprints stored as strings |
| Credential phases | AD phases in `CredentialProvider` | Two paths: fingerprint and token. No phases. |
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Hybrid auth model | [ADR-004](../../decisions/004-auth-as-shared-core.md) | Endpoint resolves TLS-level, handler resolves protocol-level |
| AuthContext with optional Identity | [ADR-011](../../decisions/011-authcontext-structure.md) | Explicit None, not "partially authenticated" |
| AuthContext is immutable in handle() | [ADR-011](../../decisions/011-authcontext-structure.md) | Handlers create local variables for resolved identity |
| Two resolution paths | [ADR-004](../../decisions/004-auth-as-shared-core.md) | Fingerprint and token, not phased auth |
| Handler stores resolved identity on Connection | OQ-11 (resolved) | `connection.set_identity()` — write-once-read-many for observability |
## Open Questions
None. All auth-related open questions are resolved.
## Security Constraints
These are security-critical implementation requirements, not architectural decisions (the architecture is locked by the ADRs above). They are documented here so implementation agents don't miss them.
- **Token entropy**: generated `alk_` tokens must have ≥128 bits of entropy. The prefix (first 8 chars) is for O(1) lookup and is not secret — it appears in logs by design. SHA-256 of the full token allows offline verification; this is safe only if the full token is high-entropy. The prefix alone must not be sufficient to authenticate.
- **Config reload must be authenticated**: a reload that adds an authorized fingerprint or API key grants access immediately (see [config.md](config.md)). The reload trigger must be local-only (SIGHUP, file watch) or an admin-scoped call protocol operation. A malicious reload is equivalent to root-level privilege grant.
- **Connection-level identity is for observability only**: `Connection::set_identity` stores the handler-resolved identity for logging/audit. Per-request identity (`OperationContext.identity`) takes precedence for ACL. See OQ-11.
- **Cryptographic nonces use OsRng**: AES-GCM IVs and any other cryptographic nonces must use `OsRng` (or equivalent CSPRNG), not `rand::random()`. IV reuse under the same key is catastrophic for GCM (authenticity breaks, two-time-pad on plaintext). The vault implementation (`crates/alknet-vault/src/encryption.rs`) must use `OsRng` for IV generation.
- **Derived keys are zeroized on drop**: cached derived keys (`CachedKey`) must derive `Zeroize` and `ZeroizeOnDrop`. When the cache evicts an entry (LRU) or the process exits without explicit `lock()`, derived private keys must not linger in freed heap memory. The cache must clear on drop, not just on explicit `lock()`.
- **No `unwrap()` or `expect()` outside tests**: poisoned lock recovery uses `unwrap_or_else(|e| e.into_inner())` or explicit error propagation. A panic in one vault operation must not brick the vault for all other operations.

View File

@@ -0,0 +1,235 @@
---
status: draft
last_updated: 2026-06-22-21
---
# Configuration
StaticConfig, DynamicConfig, ArcSwap, and ConfigReloadHandle.
## StaticConfig
Immutable configuration resolved at startup. Cannot be changed without restarting the endpoint.
```rust
pub struct StaticConfig {
/// Bind address for the quinn endpoint (e.g., "0.0.0.0:4433").
/// None if the quinn endpoint is not configured (iroh-only node).
pub listen_addr: Option<SocketAddr>,
/// TLS identity mode for the quinn endpoint.
/// Required if listen_addr is Some.
pub tls_identity: Option<TlsIdentity>,
/// iroh relay URL (e.g., "https://relay.iroh.network/").
/// None if the iroh endpoint is not configured.
pub iroh_relay: Option<RelayUrl>,
/// Drain timeout for graceful shutdown (default: 2 seconds).
pub drain_timeout: Duration,
}
/// TLS identity configuration for the quinn endpoint.
pub enum TlsIdentity {
/// X.509 certificate for domain-facing identity.
/// Required for browser/WebTransport clients.
X509 {
cert: PathBuf,
key: PathBuf,
},
/// RFC 7250 raw Ed25519 public key.
/// No domain, no CA, no cert renewal. Key = identity.
/// Same model as iroh's NodeId, but for direct QUIC connections.
/// `SecretKey` is `iroh::SecretKey` (Ed25519) — re-exported from iroh,
/// which alknet-core already depends on (feature-gated, ADR-010). The
/// key can be derived from alknet-vault at the assembly layer
/// (endpoint.md) or generated fresh. See OQ-12, W14.
RawKey(iroh::SecretKey),
/// Self-signed X.509 cert for development.
/// Generated on startup, not validated by external clients.
SelfSigned,
}
```
### Why `TlsIdentity` instead of `tls_cert`/`tls_key` options
TLS identity in alknet has two distinct use cases, not one. The original `tls_cert: Option<PathBuf>` / `tls_key: Option<PathBuf>` assumed X.509 was the only TLS identity model. RFC 7250 raw public keys (used by iroh, supported by rustls) provide a fundamentally different mode: Ed25519 key as identity, no X.509, no CA, no domain. This is the default for most alknet nodes — it works natively with SSH auth and git. X.509 certs are for domain-hosted services and browser/WebTransport clients, which don't support RFC 7250.
The `TlsIdentity` enum captures both use cases plus a development mode. See OQ-12 for the full rationale.
### Key differences from reference implementation
The reference `StaticConfig` (in `alknet-main/crates/alknet-core/src/config/static_config.rs`) is SSH-centric: it holds `host_key`, `host_key_algorithm`, `proxy_config`, `stealth`, `transport_mode`, and `listeners`. The new model removes all of these:
- **No `host_key`/`host_key_algorithm`**: SSH host keys are managed by the SSH handler, not by core config. The endpoint uses TLS certs, not SSH host keys.
- **No `proxy_config`**: Outbound proxy is an SSH-specific concern (SOCKS5/HTTP CONNECT forwarding). Not in core config.
- **No `stealth`**: ALPN eliminates the need for stealth/byte-peeking. See [ADR-001](../../decisions/001-alpn-protocol-dispatch.md).
- **No `transport_mode`/`listeners`**: The old `ServeTransportMode` and `ListenerConfig` enum are replaced by `listen_addr` (quinn) and `iroh_relay` (iroh). Both are optional — a node can use either or both. See [ADR-010](../../decisions/010-alpn-router-and-endpoint.md).
### Construction
`StaticConfig` is constructed by the CLI binary from CLI arguments or a config file. The exact shape of `StartupOptions` (or whatever the CLI uses) is a CLI concern, not a core concern. alknet-core provides `StaticConfig` as a data structure; the CLI is responsible for populating it.
```rust
// The CLI binary constructs StaticConfig from its own options/config.
// StartupOptions is NOT a core type — it belongs to the alknet CLI binary.
// alknet-core receives a fully populated StaticConfig.
// P2P / key-based identity (default for most nodes)
let p2p_config = StaticConfig {
listen_addr: Some("0.0.0.0:4433".parse()?),
tls_identity: Some(TlsIdentity::RawKey(iroh::SecretKey::generate())),
iroh_relay: None,
drain_timeout: Duration::from_secs(2),
};
// Domain-hosted service (relays, public services, browsers)
let domain_config = StaticConfig {
listen_addr: Some("0.0.0.0:4433".parse()?),
tls_identity: Some(TlsIdentity::X509 {
cert: "/path/to/cert.pem".into(),
key: "/path/to/key.pem".into(),
}),
iroh_relay: None,
drain_timeout: Duration::from_secs(2),
};
```
## DynamicConfig
Runtime-reloadable configuration. Hot-reloaded via `ArcSwap` without restarting the endpoint.
```rust
#[derive(Debug, Clone)]
pub struct DynamicConfig {
pub auth: AuthPolicy,
pub rate_limits: RateLimitConfig,
}
```
### AuthPolicy
Authorization policy derived from authorized keys, certificate authorities, and API keys.
```rust
pub struct AuthPolicy {
/// SHA-256 fingerprints of authorized keys (SSH keys, TLS client certs).
/// Stored as strings to avoid russh dependency in core.
pub authorized_fingerprints: HashSet<String>,
/// API keys for token-based auth.
pub api_keys: Vec<ApiKeyEntry>,
}
```
Certificate authority entries for cert-based auth will be added when
alknet-ssh is implemented. The `cert_authorities` field is omitted from v1
to avoid referencing an undefined type. Adding it back is additive (a new
field on `AuthPolicy` is non-breaking for existing config files that don't
use it). alknet-ssh will define `CertAuthorityEntry` with the necessary
fields (public key, principals, options).
This replaces the reference implementation's `AuthPolicy` which depended on `russh::keys::PublicKey`. The new version stores fingerprints as strings, not russh types. This removes the russh dependency from alknet-core.
### ApiKeyEntry
```rust
pub struct ApiKeyEntry {
/// Key prefix (first 8 chars of the key). Used for O(1) lookup.
pub prefix: String,
/// SHA-256 hash of the full key. Used for verification.
pub hash: String,
/// Authorization scopes granted by this key.
pub scopes: Vec<String>,
/// Human-readable description.
pub description: String,
/// Unix timestamp when the key expires. None = never expires.
pub expires_at: Option<u64>,
}
```
Carries forward from the reference implementation with no changes.
### RateLimitConfig
```rust
pub struct RateLimitConfig {
pub max_connections_per_ip: usize,
pub max_auth_attempts: usize,
}
```
Carries forward from the reference implementation. Rate limits are entirely dynamic — `StaticConfig` does not contain rate limit fields. The CLI binary sets initial `RateLimitConfig` values when constructing the initial `DynamicConfig`. Hot-reloading via `ConfigReloadHandle` replaces rate limits immediately without restart.
## ArcSwap Pattern
`DynamicConfig` is wrapped in `Arc<ArcSwap<DynamicConfig>>` for lock-free reads and atomic swaps.
```rust
let dynamic = Arc::new(ArcSwap::new(Arc::new(DynamicConfig::default())));
```
- **Reads**: `dynamic.load()` returns `Arc<DynamicConfig>`. Multiple readers can hold references simultaneously without blocking.
- **Writes**: `dynamic.store(Arc::new(new_config))` atomically replaces the config. All subsequent reads see the new config.
- **No locks**: `ArcSwap` uses atomic operations. No reader is ever blocked by a writer.
This pattern carries forward directly from the reference implementation (`alknet-main/crates/alknet-core/src/config/dynamic_config.rs`).
## ConfigReloadHandle
```rust
pub struct ConfigReloadHandle {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigReloadHandle {
pub fn reload(&self, new_config: DynamicConfig);
pub fn dynamic(&self) -> Arc<DynamicConfig>;
}
```
- `reload()`: Atomically replaces the dynamic config. All subsequent reads (including in-flight `IdentityProvider` calls) see the new config.
- `dynamic()`: Returns the current config as `Arc<DynamicConfig>`.
The CLI binary creates a `ConfigReloadHandle` and passes it to a config watcher (file watcher, SIGHUP handler, or call protocol operation) that calls `reload()` when config changes are detected.
**Config reload is a privilege-escalation path.** `ConfigIdentityProvider` reads from `ArcSwap<DynamicConfig>`, so a reload that adds an authorized fingerprint or API key grants access immediately. A malicious reload is equivalent to root-level privilege grant. The reload trigger **must be authenticated/local-only**: SIGHUP (local signal), local file watch, or an admin call protocol operation with the same auth treatment as any other mutation (requires `admin` scope, ADR-015). The implementation must not ship a reload endpoint with no auth "for convenience."
## ConfigError
```rust
pub enum ConfigError {
InvalidFlag { name: String },
KeyFileNotFound { path: String },
BindFailed(io::Error),
TlsConfig(io::Error),
IncompatibleOptions,
}
```
Simplified from the reference implementation. Removes proxy-specific errors (now an SSH concern) and listener validation errors (no more `ListenerConfig` enum).
## Key Differences from Reference Implementation
| Aspect | Reference | New Model |
|--------|-----------|-----------|
| StaticConfig fields | SSH host key, stealth, transport_mode, listeners, proxy | listen_addr, TLS cert/key, drain_timeout |
| DynamicConfig.auth | `HashSet<PublicKey>` (russh types) | `HashSet<String>` (fingerprint strings) |
| ListenerConfig | Enum with Stream/Http/Dns variants | Eliminated — single endpoint, ALPN dispatch |
| TransportMode | Tcp/Tls/Iroh | Eliminated — always QUIC+TLS |
| Stealth mode | Byte-peeking HTTP/SSH detection | Eliminated — ALPN handles protocol detection |
| ForwardingPolicy | In DynamicConfig | Moved to handler-specific config (SSH) |
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| No russh dependency in core | [ADR-003](../../decisions/003-crate-decomposition.md) | Core is ALPN-agnostic; russh is an alknet-ssh dependency |
| ArcSwap for dynamic config | Carry-forward from reference | Lock-free reads, atomic swaps |
| No ListenerConfig | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | Single endpoint, ALPN replaces multiple listener types |

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