30 Commits

Author SHA1 Message Date
5fcbd65ba2 archived: project renamed to Alknet, see git.alk.dev/alkdev/alknet 2026-06-05 09:36:58 +00:00
af7f4d0006 docs: add auth, call protocol architecture specs and ADRs 023-025
Unified authentication (ADR-023): SSH and WebTransport auth share the same
Ed25519 key material. Token auth uses signed timestamps verified against the
same authorized_keys set. IdentityProvider trait decouples core from identity
storage.

Bidirectional call protocol (ADR-024): Generalizes control channel (ADR-018)
to support hub→spoke and spoke→hub calls. Operation paths use /{spoke}/{service}/{op}
format for three-level routing. EventEnvelope wire format, five call events,
PendingRequestMap for correlation.

Handler/spec separation (ADR-025): Downstream consumers register operations
without modifying core. OperationRegistry maps paths to specs + handlers.
Service discovery via /services/list and /services/schema.

Resolves OQ-17 (transport-aware auth), OQ-21 (spoke routing), OQ-CFG-04 and
OQ-CFG-06 (WebTransport auth and transport-aware auth layer). Adds OQ-18
through OQ-22 for remaining open questions.
2026-06-05 08:19:41 +00:00
41062d810e docs: add configuration architecture research
Explore static/dynamic config split, hot-reloadable auth via ArcSwap,
forwarding policy, multi-transport listeners, and config file format.
Documents three problems: no auth hot-reload, no forwarding access control,
no structured config beyond CLI flags.

Key findings:
- Static config (transport, TLS, host key) loaded once at startup
- Dynamic config (auth, forwarding, rate limits) reloadable via ArcSwap
- ForwardingPolicy with rule-based allow/deny, first-match evaluation
- Multi-transport: Server spawns Vec<ListenerConfig> sharing auth config
- WebTransport out of scope for now (requires separate auth model)
- Proposes ADR-020 (static/dynamic split), ADR-021 (forwarding policy),
  ADR-022 (multi-transport listeners)

Adds OQ-12 through OQ-17 to open-questions.md.
2026-06-04 09:40:58 +00:00
5ffcf9232b feat(iroh): add from_endpoint constructors for shared iroh Endpoint
Enable running wraith alongside iroh-blobs, iroh-gossip, and iroh-docs
on the same QUIC endpoint (one connection per peer, multiplexed by ALPN).

- IrohTransport::from_endpoint(node_id, endpoint) for client-side shared endpoint
- IrohAcceptor::from_endpoint(endpoint) for server-side shared endpoint
- Export ALPN constant as IROH_ALPN for Router registration
- Add owned() method to track whether the endpoint was created internally
- Existing new()/bind() constructors unchanged (backwards compatible)
- Add tests for from_endpoint constructors and shared endpoint connectivity
2026-06-03 10:24:07 +00:00
d85c882635 feat!: harden SSH server handler security
- Restrict auth methods to PUBLICKEY only (no none, password, hostbased,
  or keyboard-interactive advertised during negotiation)
- Log all denied channel types (session, x11, forwarded-tcpip) and
  dangerous request types (exec, shell, subsystem, pty, env, x11, agent)
- Explicitly reject all dangerous channel request handlers (exec, shell,
  subsystem, pty, env, x11, agent forwarding) with channel_failure
  responses instead of russh's default silent Ok(()) which leaves clients
  hanging and is a footgun if session channels are ever allowed
- Explicitly reject tcpip_forward, streamlocal_forward with logged warnings
- Log signal requests at debug level (harmless, no response needed)
- Override handlers in both core ServerHandler and NapiServerHandler
- Add tracing dependency to wraith-napi for security event logging
- Set preferred algorithms explicitly (russh::Preferred::DEFAULT which
  uses only modern KEX/cipher/MAC algorithms)
2026-06-03 09:04:01 +00:00
a7595f1718 fix: update repo URLs from github.com/alkdev to git.alk.dev/alkdev 2026-06-03 06:45:22 +00:00
37ff929a42 docs: add iroh and TLS NAPI examples to README 2026-06-03 06:30:39 +00:00
150b1f3ae5 feat(napi): add TLS and iroh transport support to serve() and connect() 2026-06-03 05:58:05 +00:00
053ace6fcc docs: add README, LICENSE files, and crate/module-level doc comments
Add top-level README.md with alpha status warning, quick start guide,
architecture overview, feature flags, transport modes, auth docs, and
Node.js API examples.

Add dual LICENSE-MIT and LICENSE-APACHE files.

Add comprehensive crate-level and module-level rustdoc to all three
crates (wraith-core, wraith, wraith-napi) and all public modules
(transport, client, server, auth, socks5, error). Add doc comments to
key public types (Transport, TransportAcceptor, ConnectOptions,
ClientSession, Server, ServeOptions, KeySource, ServerAuthConfig, etc).

Update Cargo.toml files with workspace-level package metadata
(version, edition, license, repository) and crate descriptions.
2026-06-02 22:03:10 +00:00
f63589a5ca chore: complete review/complete-system — final review passed, all criteria met 2026-06-02 20:27:03 +00:00
9b06f26a3c chore: complete review/server-and-client with fixes applied 2026-06-02 20:22:22 +00:00
e49aef05d3 fix: wire channel proxy into handler, add client reconnection with backoff, fix ADR-006 violations
- handler.channel_open_direct_tcpip now proxies non-wraith channels via
  connect_outbound+proxy_channel instead of dropping them
- ClientSession.run() spawns reconnect monitor that detects handle closure,
  reconnects with exponential backoff (1s/2s/4s/8s/16s/30s cap),
  and re-registers remote port forwards
- Remove server-side logging of tunnel destinations (ADR-006 compliance)
- Remove debug-level logging of proxy targets in channel_proxy
2026-06-02 20:22:13 +00:00
f057e868ce chore: complete Gen 8 + Gen 9 meta tasks (cli-layer, napi-layer, serve-function, serve-command) 2026-06-02 20:08:34 +00:00
0fdb6cd782 feat(napi): implement serve() function with WraithServer, WraithServerStream, and ConnectionInfo
Expose NAPI serve() per ADR-016. WraithServer provides close() and
onConnection(callback) for receiving SSH channel streams from
incoming connections. Each connection produces a WraithServerStream
(Duplex-like read/write/close) with ConnectionInfo (remoteAddr,
transportKind). Supports TCP transport with optional authorizedKeys
and certAuthority auth. TLS and iroh transports return helpful errors
indicating future support.
2026-06-02 20:05:13 +00:00
62d57dd477 Implement wraith serve CLI subcommand with clap
Add serve subcommand with all flags matching server.md CLI interface:
--key, --authorized-keys, --cert-authority, --transport, --listen,
--tls-cert, --tls-key, --acme-domain, --stealth, --proxy,
--iroh-relay, --max-connections-per-ip, --max-auth-attempts.

--key is required, --transport defaults to tcp, --listen defaults to
0.0.0.0:22. --stealth validates TLS transport. --acme-domain requires
acme feature flag. --transport iroh prints endpoint ID on startup.
Key inputs accept file paths. Errors reported to stderr with non-zero
exit code. Also adds acme feature flag and rustls-pemfile/rustls-pki-types
dependencies for TLS cert loading.
2026-06-02 12:28:37 +00:00
c7b8c5c5e0 chore: complete meta/server-layer — all server module tasks done 2026-06-02 12:12:53 +00:00
6297c07383 chore: fix clippy dead_code warning on handler.transport, update serve-loop task to completed 2026-06-02 12:11:54 +00:00
32a8c9a725 Merge branch 'feat/server/serve-loop' 2026-06-02 12:04:09 +00:00
373b053820 feat(server): implement serve loop, ServeOptions, graceful shutdown, and integration test
- Add ServeOptions struct with all CLI fields (key, authorized_keys, cert_authority,
  transport_mode, listen_addr, tls_cert, tls_key, acme_domain, stealth, proxy,
  iroh_relay, max_connections_per_ip, max_auth_attempts)
- ServeOptions::key/authorized_keys accept KeySource (file or in-memory)
- Server::new(opts) creates server with bound russh config, auth config, rate limiter
- Server::run(acceptor, endpoint_info) enters accept loop: rate limit check -> create
  handler -> russh::server::run_stream()
- Stealth mode integration: protocol detection before run_stream() on TLS connections
- Graceful shutdown: Server::shutdown() sends SSH disconnect, waits drain timeout,
  aborts remaining sessions
- SIGTERM/SIGINT handler on unix platforms
- iroh mode: prints endpoint ID on startup
- Integration test: start server, shutdown signal, verify clean exit
- Re-export Server, ServeOptions, ServeTransportMode, ServeError from lib.rs
2026-06-02 11:57:30 +00:00
94feb5fdac feat(cli): implement wraith connect subcommand with clap derive
All CLI flags from client.md: --server, --peer, --transport (default tcp),
--identity, --socks5 (default 127.0.0.1:1080), --forward (repeatable),
--remote-forward (repeatable), --proxy, --iroh-relay, --tls-server-name,
--insecure. Env var defaults: WRAITH_SERVER, WRAITH_IDENTITY. Validates
--server required for tcp/tls, --peer required for iroh, --identity required.
Warns on --proxy with --transport tcp (ADR-019). Translates args to
ConnectOptions and calls ClientSession::new(opts).run().await. Errors to
stderr with non-zero exit.
2026-06-02 11:39:57 +00:00
f13a1c985f Merge remote-tracking branch 'origin/feat/server/channel-proxy'
# Conflicts:
#	crates/wraith-core/src/error.rs
#	crates/wraith-core/src/server/mod.rs
2026-06-02 11:32:28 +00:00
365b11d19e Merge remote-tracking branch 'origin/feat/server/stealth-mode'
# Conflicts:
#	crates/wraith-core/src/error.rs
#	crates/wraith-core/src/server/mod.rs
2026-06-02 11:14:06 +00:00
7dcf7502b7 feat(server): implement stealth mode protocol multiplexing (ADR-017)
Add stealth mode detection that peeks at the first bytes after TLS handshake
to determine SSH vs HTTP protocol. SSH connections proceed to russh handler;
non-SSH connections receive a fake nginx 404 response, making the server
indistinguishable from an ordinary HTTPS site to scanners and DPI systems.

- ProtocolDetection enum (Ssh, Http) for protocol classification
- detect_protocol() uses BufReader::fill_buf() to peek without consuming bytes
- send_fake_nginx_404() writes HTTP/1.1 404 + Server: nginx headers
- validate_stealth_config() enforces TLS transport requirement for stealth
- 17 unit tests covering SSH banner, HTTP, random data, and edge cases
2026-06-02 11:13:15 +00:00
585913d3c8 Merge remote-tracking branch 'origin/feat/napi/connect-function'
# Conflicts:
#	crates/wraith-core/src/error.rs
2026-06-02 11:11:14 +00:00
243243a82f Implement NAPI connect() function — single SSH channel as duplex stream
- Add WraithConnectOptions struct with napi fields: server, peer, transport,
  identity (string path or Buffer), tlsServerName, insecure, irohRelay, proxy
- Add WraithStream napi class wrapping SSH channel read/write halves via
  ChannelStream::into_stream() + tokio::io::split()
- Implement connect() async function: transport creation (tcp, tls), SSH client
  connection, authenticate, open direct_tcpip channel, return WraithStream
- Identity field accepts file path (string) or in-memory key data (Buffer)
- All Rust errors marshalled to JavaScript exceptions with descriptive messages
- Add ForwardError enum to wraith-core (required by forward.rs)
- Enable tls, iroh features on wraith-core dependency
- 7 unit tests for key source resolution and address parsing
2026-06-02 11:10:42 +00:00
2ab5eeda53 Merge remote-tracking branch 'origin/feat/client/connect-options' 2026-06-02 11:07:54 +00:00
128affd264 Implement ConnectOptions struct and ClientSession orchestration with graceful shutdown
Adds client/connect.rs with ConnectOptions (programmatic API per ADR-011),
ClientSession::new() for SSH session establishment, ClientSession::run()
for SOCKS5 + port forwards + shutdown, and graceful shutdown via
SIGTERM/SIGINT with SSH disconnect and 2s drain timeout.
2026-06-02 11:07:33 +00:00
5a2b535605 Merge remote-tracking branch 'origin/feat/server/rate-limiting-and-logging'
# Conflicts:
#	crates/wraith-core/src/error.rs
#	crates/wraith-core/src/server/handler.rs
#	crates/wraith-core/src/server/mod.rs
2026-06-02 11:06:18 +00:00
24b70f5651 Implement server rate limiting and fail2ban-friendly structured logging
Add ConnectionRateLimiter (HashMap<IpAddr, usize>) and AuthAttemptLimiter
with check/on_connect/on_disconnect and check/on_failure methods.
Integrate into ServerHandler with structured tracing::info! logging for
auth attempts, connection opened/closed events. No logging of tunnel
destinations per ADR-006. Also add ForwardError type and fix type
annotation in forward.rs to unblock compilation.
2026-06-02 11:02:55 +00:00
f963898a05 Implement control channel routing for wraith-* reserved destinations (ADR-018)
- Add control_channel.rs with WRAITH_CONTROL_DESTINATION, WRAITH_PREFIX constants
- Add ControlChannelHandler trait and ControlChannelRouter for routing logic
- Add DuplexStream supertrait for Box<dyn> compatibility
- Server handler rejects wraith-* destinations when no handler configured
- Add ForwardError type to fix pre-existing compilation error
- Unit tests: reserved detection, non-reserved pass-through, prefix matching
2026-06-02 11:01:54 +00:00
47 changed files with 6955 additions and 171 deletions

26
Cargo.lock generated
View File

@@ -2395,6 +2395,7 @@ version = "3.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"ctor",
"futures",
@@ -2402,6 +2403,7 @@ dependencies = [
"napi-sys",
"nohash-hasher",
"rustc-hash",
"tokio",
]
[[package]]
@@ -3861,6 +3863,15 @@ dependencies = [
"x509-parser 0.16.0",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -5583,7 +5594,13 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"iroh",
"rustls",
"rustls-acme",
"rustls-pemfile",
"rustls-pki-types",
"tokio",
"url",
"wraith-core",
]
@@ -5593,6 +5610,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"futures",
"ipnetwork",
"iroh",
"rand 0.10.1",
@@ -5618,8 +5636,16 @@ dependencies = [
name = "wraith-napi"
version = "0.1.0"
dependencies = [
"async-trait",
"iroh",
"napi",
"napi-derive",
"russh",
"rustls-pemfile",
"rustls-pki-types",
"tokio",
"tracing",
"url",
"wraith-core",
]

View File

@@ -4,4 +4,10 @@ members = [
"crates/wraith",
"crates/wraith-napi",
]
resolver = "2"
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://git.alk.dev/alkdev/wraith"

192
LICENSE-APACHE Normal file
View File

@@ -0,0 +1,192 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name), or refer to, the Work.
(Note: Derivative Works shall not include works that remain separable from,
or merely link (or bind by name) to the interfaces of, the Work and
Derivative Works thereof.)
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025-2026 Alk Development
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 Alk Development
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

43
README.md Normal file
View File

@@ -0,0 +1,43 @@
# Wraith
> **This project has been renamed to [Alknet](https://git.alk.dev/alkdev/alknet).**
>
> All future development continues under the **Alknet** name at:
>
> - **Primary**: <https://git.alk.dev/alkdev/alknet>
> - **Mirror**: <https://github.com/alkimiadev/alknet>
>
> This repository is archived. No further changes will be made here. Please
> update your dependencies and references to the new repository. The code, crate
> names, CLI binary name, and all identifiers will be updated from `wraith` to
> `alknet` (e.g. `wraith-core` → `alknet-core`, `wraith serve` → `alknet serve`).
>
> The license (MIT OR Apache-2.0) remains the same.
---
A self-hostable SSH-based tunnel tool that provides VPN-like functionality without being a VPN protocol.
## What it does
- **Private tunneling** — Route traffic to internal services (Postgres, Redis, APIs) over SSH
- **Censorship circumvention** — SSH over TLS on port 443 is indistinguishable from HTTPS to DPI
- **NAT traversal** — The iroh transport enables peer-to-peer connections without public IPs or port forwarding
- **Service mesh connectivity** — Lightweight transport layer for event systems via reserved destinations
The core insight: SSH tunnels work because SSH is fundamental infrastructure. Blocking it breaks the internet.
## Quick start
See the [Alknet repository](https://git.alk.dev/alkdev/alknet) for current build and usage instructions.
## License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

View File

@@ -1,7 +1,10 @@
[package]
name = "wraith-core"
version = "0.1.0"
edition = "2021"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Core library for Wraith: pluggable SSH tunnel transport, SOCKS5 proxy, port forwarding, and authentication"
repository.workspace = true
[lib]
name = "wraith_core"

View File

@@ -1,15 +1,29 @@
//! 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::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64};
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,

View File

@@ -1,3 +1,8 @@
//! 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.
pub mod client_auth;
pub mod keys;
pub mod server_auth;

View File

@@ -1,3 +1,9 @@
//! 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;
@@ -10,6 +16,10 @@ use russh::keys::{Certificate, PublicKey};
use super::keys::{CertAuthorityEntry, KeySource, load_cert_authority_entries, load_public_keys};
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>,

View File

@@ -1,3 +1,11 @@
//! 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;

View File

@@ -0,0 +1,854 @@
//! 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 a wraith client session.
///
/// Construct with `ConnectOptions::new(key_source)` and chain builder methods.
/// Call `validate()` before passing to `ClientSession::new()`.
///
/// ```
/// use wraith_core::client::{ConnectOptions, TransportMode};
/// use wraith_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!("wraith 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(|_| "wraith".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("wraith.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("wraith.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::{ChannelOpener, ChannelOpenError};
use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex};
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,3 +1,10 @@
//! 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;

View File

@@ -1,5 +1,17 @@
//! 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

@@ -1,3 +1,12 @@
//! Error types for wraith-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)]

View File

@@ -1,3 +1,55 @@
//! # wraith-core
//!
//! Core library for [Wraith](https://git.alk.dev/alkdev/wraith), a self-hostable SSH-based
//! tunnel tool. This crate provides the transport abstraction, SOCKS5 server, port forwarding,
//! authentication, and server handler — everything needed to build a wraith client or server
//! on top of pluggable transports.
//!
//! > **Alpha software.** This crate depends on solid libraries (russh, tokio, rustls, iroh)
//! > for core functionality, but the integration layer has not been battle-tested. Use with
//! > caution and report issues.
//!
//! # Key concepts
//!
//! - **Transport trait** — produces a duplex byte stream (`AsyncRead + AsyncWrite + Unpin + Send`)
//! that SSH consumes. Implementations: TCP, TLS, iroh (QUIC P2P).
//! - **SOCKS5 server** — the primary client interface, listening on a local port and routing
//! traffic through SSH channels.
//! - **Port forwarding** — `-L` local and `-R` remote port forwards over SSH channels.
//! - **Authentication** — Ed25519 public key and OpenSSH certificate authority. No passwords.
//! - **Server handler** — accepts SSH connections via a `TransportAcceptor` and proxies
//! `direct-tcpip` channel requests to targets (directly or via outbound proxy).
//!
//! # Feature flags
//!
//! | Feature | Default | Description |
//! |---------|---------|-------------|
//! | `tls` | yes | TLS transport via `tokio-rustls` |
//! | `iroh` | yes | iroh QUIC P2P transport |
//! | `acme` | no | ACME/Let's Encrypt auto-cert provisioning (implies `tls`) |
//! | `testutil` | no | Test utilities (for internal use) |
//!
//! # Quick example
//!
//! ```no_run
//! use std::sync::Arc;
//! use wraith_core::transport::TcpTransport;
//! use wraith_core::client::{ClientSession, ConnectOptions, TransportMode};
//! use wraith_core::auth::keys::KeySource;
//! use wraith_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 transport;
pub mod client;
pub mod server;
@@ -10,4 +62,6 @@ pub mod testutil;
pub use error::{AuthError, ChannelError, ConfigError, ForwardError, TransportError};
pub use transport::{Transport, TransportAcceptor, TransportInfo, TransportKind};
pub use client::channel_manager::{ChannelManager, ForwardRequest};
pub use client::channel_manager::{ChannelManager, ForwardRequest};
pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
pub use server::serve::{Server, ServeError, ServeOptions, ServeTransportMode};

View File

@@ -1,3 +1,9 @@
//! 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};
@@ -142,16 +148,13 @@ async fn connect_http_connect(
}
}
fn map_connection_error(e: std::io::Error, target: SocketAddr) -> ChannelProxyError {
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,
_ => {
tracing::debug!(error = %e, "outbound connection failed to {:?}", target);
ChannelProxyError::Io(e)
}
_ => ChannelProxyError::Io(e),
}
}

View File

@@ -0,0 +1,192 @@
//! Control channel routing for reserved `wraith-*` destinations.
//!
//! SSH channels opened with a destination starting with `wraith-` 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 WRAITH_CONTROL_DESTINATION: &str = "wraith-control";
pub const WRAITH_PREFIX: &str = "wraith-";
pub fn is_reserved_destination(host: &str) -> bool {
host.starts_with(WRAITH_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",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::duplex;
#[test]
fn wraith_control_destination_constant() {
assert_eq!(WRAITH_CONTROL_DESTINATION, "wraith-control");
}
#[test]
fn wraith_prefix_constant() {
assert_eq!(WRAITH_PREFIX, "wraith-");
}
#[test]
fn reserved_destination_detected() {
assert!(is_reserved_destination("wraith-control"));
assert!(is_reserved_destination("wraith-status"));
assert!(is_reserved_destination("wraith-events"));
assert!(is_reserved_destination("wraith-"));
}
#[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("wraith.example.com"));
assert!(!is_reserved_destination(""));
assert!(!is_reserved_destination("wrait-control"));
assert!(!is_reserved_destination("WRAITH-control"));
}
#[test]
fn prefix_matching_case_sensitive() {
assert!(!is_reserved_destination("Wraith-control"));
assert!(!is_reserved_destination("WRAITH-control"));
assert!(is_reserved_destination("wraith-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(WRAITH_CONTROL_DESTINATION));
}
}

View File

@@ -1,14 +1,18 @@
use std::net::SocketAddr;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;
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::ServerAuthConfig;
const WRAITH_PREFIX: &str = "wraith-";
use crate::server::control_channel::{
ControlChannelHandler, ControlChannelRouter, WRAITH_PREFIX,
};
use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
#[derive(Debug, Clone)]
pub enum ProxyMode {
@@ -22,11 +26,35 @@ pub struct ProxyConfig {
pub mode: ProxyMode,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TransportKind {
Tcp,
Tls,
Iroh,
}
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"),
}
}
}
pub struct ServerHandler {
auth_config: Arc<ServerAuthConfig>,
#[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,
}
impl ServerHandler {
@@ -34,13 +62,82 @@ impl ServerHandler {
auth_config: Arc<ServerAuthConfig>,
outbound_proxy: Option<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 {
auth_config,
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(),
}
}
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]
@@ -52,6 +149,23 @@ impl Handler for ServerHandler {
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
@@ -64,6 +178,7 @@ impl Handler for ServerHandler {
Ok(()) => {
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "accept",
"auth attempt"
@@ -71,8 +186,10 @@ impl Handler for ServerHandler {
Ok(Auth::Accept)
}
Err(_) => {
self.auth_limiter.on_failure();
tracing::info!(
remote_addr = %remote_addr_display,
user = user,
key_fingerprint = %fingerprint,
result = "reject",
"auth attempt"
@@ -94,23 +211,48 @@ impl Handler for ServerHandler {
_session: &mut Session,
) -> Result<bool, Self::Error> {
if host_to_connect.starts_with(WRAITH_PREFIX) {
tracing::info!(
host = host_to_connect,
port = port_to_connect,
"routing to internal control channel handler"
);
if !self.control_channel_router.has_handler() {
return Ok(false);
}
let _ = channel;
return Ok(true);
}
let _ = (host_to_connect, port_to_connect, originator_address, originator_port, channel);
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,
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)
}
@@ -119,22 +261,208 @@ impl Handler for ServerHandler {
_channel: Channel<Msg>,
_originator_address: &str,
_originator_port: u32,
_session: &mut Session,
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,
host_to_connect: &str,
port_to_connect: u32,
_originator_address: &str,
_originator_port: u32,
_session: &mut Session,
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)]
@@ -174,10 +502,22 @@ mod tests {
Arc::new(ServerAuthConfig::from_keys_and_ca(None, None).unwrap())
}
fn default_limiter() -> Arc<ConnectionRateLimiter> {
Arc::new(ConnectionRateLimiter::new(0))
}
fn make_handler(
auth_config: Arc<ServerAuthConfig>,
outbound_proxy: Option<ProxyConfig>,
remote_addr: Option<SocketAddr>,
) -> ServerHandler {
ServerHandler::new(auth_config, 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 = ServerHandler::new(auth_config, None, None);
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();
@@ -187,7 +527,7 @@ mod tests {
#[tokio::test]
async fn auth_delegation_rejects_unknown_key() {
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
let mut handler = ServerHandler::new(auth_config, None, None);
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(
@@ -210,7 +550,7 @@ mod tests {
#[tokio::test]
async fn auth_delegation_empty_config_rejects_all() {
let auth_config = make_empty_auth_config();
let mut handler = ServerHandler::new(auth_config, None, None);
let mut handler = make_handler(auth_config, None, None);
let ssh_key = load_key().public_key().clone();
let result = handler
@@ -229,7 +569,7 @@ mod tests {
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 = ServerHandler::new(auth_config, None, Some(remote_addr));
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();
@@ -237,12 +577,20 @@ mod tests {
#[test]
fn reserved_wraith_destination_routing() {
assert!("wraith-control".starts_with(WRAITH_PREFIX));
assert!("wraith-status".starts_with(WRAITH_PREFIX));
assert!("wraith-events".starts_with(WRAITH_PREFIX));
assert!(!"example.com".starts_with(WRAITH_PREFIX));
assert!(!"localhost".starts_with(WRAITH_PREFIX));
assert!(!"wraith.example.com".starts_with(WRAITH_PREFIX));
use crate::server::control_channel::is_reserved_destination;
assert!(is_reserved_destination("wraith-control"));
assert!(is_reserved_destination("wraith-status"));
assert!(is_reserved_destination("wraith-events"));
assert!(!is_reserved_destination("example.com"));
assert!(!is_reserved_destination("localhost"));
assert!(!is_reserved_destination("wraith.example.com"));
}
#[test]
fn server_handler_without_control_handler_rejects_wraith_destinations() {
let auth_config = make_empty_auth_config();
let handler = make_handler(auth_config, None, None);
assert!(!handler.control_channel_router().has_handler());
}
#[test]
@@ -273,7 +621,7 @@ mod tests {
});
let remote: Option<SocketAddr> = Some("10.0.0.1:22".parse().unwrap());
let handler = ServerHandler::new(auth_config, proxy.clone(), remote);
let handler = make_handler(auth_config, proxy.clone(), remote);
assert!(handler.outbound_proxy.is_some());
assert!(handler.remote_addr.is_some());
}
@@ -281,9 +629,108 @@ mod tests {
#[test]
fn one_handler_per_connection() {
let auth_config = make_empty_auth_config();
let handler1 = ServerHandler::new(auth_config.clone(), None, Some("10.0.0.1:22".parse().unwrap()));
let handler2 = ServerHandler::new(auth_config.clone(), None, Some("10.0.0.2:22".parse().unwrap()));
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.to_string(), "tls");
assert_eq!(TransportKind::Iroh.to_string(), "iroh");
}
#[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,
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,
);
}
}

View File

@@ -1,5 +1,25 @@
pub mod channel_proxy;
pub mod handler;
//! 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 `wraith-` are reserved for internal use (control channel, ADR-018).
pub use channel_proxy::{ChannelProxyError, connect_outbound, proxy_channel};
pub use handler::{ProxyConfig, ProxyMode, ServerHandler};
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::{
ControlChannelHandler, ControlChannelRouter, DuplexStream, WRAITH_CONTROL_DESTINATION,
WRAITH_PREFIX, is_reserved_destination,
};
pub use handler::{ProxyConfig, ProxyMode, ServerHandler, TransportKind};
pub use rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
pub use serve::{Server, ServeError, ServeOptions, ServeTransportMode};
pub use stealth::{ProtocolDetection, detect_protocol, send_fake_nginx_404, validate_stealth_config};

View File

@@ -0,0 +1,200 @@
//! 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();
}
}
}

View File

@@ -0,0 +1,765 @@
//! Server configuration and accept loop.
//!
//! `Server` binds to a transport acceptor and runs an accept loop, handling
//! authentication, stealth mode protocol detection, and graceful shutdown.
//! `ServeOptions` provides a builder-pattern API for programmatic configuration.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use russh::server::{self, Config};
use tokio::io::{AsyncRead, AsyncWrite};
use tracing::{error, info, warn};
use crate::auth::keys::KeySource;
use crate::auth::server_auth::ServerAuthConfig;
use crate::error::ConfigError;
use crate::server::handler::{ProxyConfig, ProxyMode, ServerHandler, TransportKind};
use crate::server::rate_limit::ConnectionRateLimiter;
use crate::server::stealth::{self, ProtocolDetection};
const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:22";
const DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
/// Transport mode for the server listener.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServeTransportMode {
Tcp,
Tls,
Iroh,
}
impl std::fmt::Display for ServeTransportMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ServeTransportMode::Tcp => write!(f, "tcp"),
ServeTransportMode::Tls => write!(f, "tls"),
ServeTransportMode::Iroh => write!(f, "iroh"),
}
}
}
/// Programmatic configuration for a wraith server.
///
/// Construct with `ServeOptions::new(key_source)` and chain builder methods.
/// Call `validate()` before passing to `Server::new()`.
///
/// ```
/// use wraith_core::server::{ServeOptions, ServeTransportMode};
/// use wraith_core::auth::keys::KeySource;
///
/// let opts = ServeOptions::new(KeySource::File("/path/to/host_key".into()))
/// .transport_mode(ServeTransportMode::Tcp)
/// .listen_addr("0.0.0.0:22")
/// .max_connections_per_ip(5)
/// .max_auth_attempts(3);
/// opts.validate().unwrap();
/// ```
pub struct ServeOptions {
pub key: KeySource,
pub authorized_keys: Option<KeySource>,
pub cert_authority: Option<KeySource>,
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 proxy: Option<String>,
pub iroh_relay: Option<String>,
pub max_connections_per_ip: usize,
pub max_auth_attempts: usize,
}
impl ServeOptions {
pub fn new(key: KeySource) -> Self {
Self {
key,
authorized_keys: None,
cert_authority: None,
transport_mode: ServeTransportMode::Tcp,
listen_addr: DEFAULT_LISTEN_ADDR.to_string(),
tls_cert: None,
tls_key: None,
acme_domain: None,
stealth: false,
proxy: None,
iroh_relay: None,
max_connections_per_ip: 0,
max_auth_attempts: 10,
}
}
pub fn authorized_keys(mut self, source: KeySource) -> Self {
self.authorized_keys = Some(source);
self
}
pub fn cert_authority(mut self, source: KeySource) -> Self {
self.cert_authority = Some(source);
self
}
pub fn transport_mode(mut self, mode: ServeTransportMode) -> Self {
self.transport_mode = mode;
self
}
pub fn listen_addr(mut self, addr: impl Into<String>) -> Self {
self.listen_addr = addr.into();
self
}
pub fn tls_cert(mut self, path: impl Into<String>) -> Self {
self.tls_cert = Some(path.into());
self
}
pub fn tls_key(mut self, path: impl Into<String>) -> Self {
self.tls_key = Some(path.into());
self
}
pub fn acme_domain(mut self, domain: impl Into<String>) -> Self {
self.acme_domain = Some(domain.into());
self
}
pub fn stealth(mut self, enabled: bool) -> Self {
self.stealth = enabled;
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 max_connections_per_ip(mut self, max: usize) -> Self {
self.max_connections_per_ip = max;
self
}
pub fn max_auth_attempts(mut self, max: usize) -> Self {
self.max_auth_attempts = max;
self
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.stealth && self.transport_mode != ServeTransportMode::Tls {
return Err(ConfigError::InvalidFlag {
name: "stealth mode requires TLS transport (--transport tls)".to_string(),
});
}
match self.transport_mode {
ServeTransportMode::Tls => {
if self.tls_cert.is_none() && self.acme_domain.is_none() {
return Err(ConfigError::InvalidFlag {
name: "TLS transport requires --tls-cert/--tls-key or --acme-domain"
.to_string(),
});
}
if self.tls_cert.is_some() && self.tls_key.is_none() {
return Err(ConfigError::InvalidFlag {
name: "--tls-cert requires --tls-key".to_string(),
});
}
if self.tls_key.is_some() && self.tls_cert.is_none() {
return Err(ConfigError::InvalidFlag {
name: "--tls-key requires --tls-cert".to_string(),
});
}
}
ServeTransportMode::Tcp | ServeTransportMode::Iroh => {
if self.tls_cert.is_some() || self.tls_key.is_some() || self.acme_domain.is_some() {
return Err(ConfigError::IncompatibleOptions);
}
}
}
Ok(())
}
}
impl std::fmt::Debug for ServeOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServeOptions")
.field("key", &"<KeySource>")
.field("authorized_keys", &"<KeySource>")
.field("cert_authority", &"<KeySource>")
.field("transport_mode", &self.transport_mode)
.field("listen_addr", &self.listen_addr)
.field("stealth", &self.stealth)
.field("max_connections_per_ip", &self.max_connections_per_ip)
.field("max_auth_attempts", &self.max_auth_attempts)
.finish()
}
}
/// Errors that can occur during server setup and operation.
#[derive(Debug, thiserror::Error)]
pub enum ServeError {
#[error("config error: {0}")]
Config(#[from] ConfigError),
#[error("bind failed: {0}")]
BindFailed(anyhow::Error),
#[error("key load failed: {0}")]
KeyLoadFailed(ConfigError),
#[error("accept failed")]
AcceptFailed,
}
struct ActiveSession {
handle: server::Handle,
join: tokio::task::JoinHandle<()>,
}
/// The wraith SSH server.
///
/// Accepts connections over any `TransportAcceptor`, authenticates via Ed25519 keys
/// or certificate authority, and proxies `direct-tcpip` channels to their targets.
/// Supports stealth mode (TLS only), outbound proxy routing, and connection rate limiting.
pub struct Server {
config: Arc<server::Config>,
auth_config: Arc<ServerAuthConfig>,
connection_limiter: Arc<ConnectionRateLimiter>,
outbound_proxy: Option<ProxyConfig>,
stealth: bool,
transport_mode: ServeTransportMode,
listen_addr: String,
max_auth_attempts: usize,
shutdown_tx: tokio::sync::watch::Sender<bool>,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
sessions: Arc<tokio::sync::Mutex<Vec<ActiveSession>>>,
}
impl Server {
pub fn new(opts: ServeOptions) -> Result<Self, ServeError> {
opts.validate().map_err(ServeError::Config)?;
let private_key =
crate::auth::keys::load_private_key(opts.key.clone()).map_err(ServeError::KeyLoadFailed)?;
let auth_config = Arc::new(
ServerAuthConfig::from_keys_and_ca(opts.authorized_keys.clone(), opts.cert_authority.clone())
.map_err(ServeError::KeyLoadFailed)?,
);
let config = Arc::new(Config {
keys: vec![private_key],
max_auth_attempts: opts.max_auth_attempts,
methods: russh::MethodSet::PUBLICKEY,
preferred: russh::Preferred::DEFAULT,
..Default::default()
});
let outbound_proxy = parse_proxy_config(opts.proxy.as_deref());
let connection_limiter = Arc::new(ConnectionRateLimiter::new(opts.max_connections_per_ip));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
Ok(Self {
config,
auth_config,
connection_limiter,
outbound_proxy,
stealth: opts.stealth,
transport_mode: opts.transport_mode,
listen_addr: opts.listen_addr,
max_auth_attempts: opts.max_auth_attempts,
shutdown_tx,
shutdown_rx,
sessions: Arc::new(tokio::sync::Mutex::new(Vec::new())),
})
}
pub fn shutdown_sender(&self) -> tokio::sync::watch::Sender<bool> {
self.shutdown_tx.clone()
}
pub async fn shutdown(&self) -> Result<(), ServeError> {
info!("initiating graceful shutdown");
let _ = self.shutdown_tx.send(true);
{
let sessions = self.sessions.lock().await;
for session in sessions.iter() {
if let Err(e) = session.handle.disconnect(
russh::Disconnect::ByApplication,
"shutdown".to_string(),
String::new(),
).await {
warn!("failed to send SSH disconnect: {e}");
}
}
}
tokio::time::sleep(DRAIN_TIMEOUT).await;
{
let mut sessions = self.sessions.lock().await;
for session in sessions.drain(..) {
session.join.abort();
}
}
info!("graceful shutdown complete");
Ok(())
}
pub fn is_shutdown(&self) -> bool {
*self.shutdown_rx.borrow()
}
pub async fn run<A>(self, acceptor: A, endpoint_info: Option<&str>) -> Result<(), ServeError>
where
A: crate::transport::TransportAcceptor,
{
let transport_kind = match self.transport_mode {
ServeTransportMode::Tcp => TransportKind::Tcp,
ServeTransportMode::Tls => TransportKind::Tls,
ServeTransportMode::Iroh => TransportKind::Iroh,
};
if self.transport_mode == ServeTransportMode::Iroh {
if let Some(id) = endpoint_info {
info!("wraith server running: transport=iroh endpoint_id={}", id);
} else {
info!("wraith server running: transport=iroh");
}
} else {
info!(
"wraith server running: transport={} listen={}",
self.transport_mode, self.listen_addr
);
}
let server = Arc::new(self);
let mut shutdown_rx = server.shutdown_rx.clone();
#[cfg(unix)]
let signal_done = {
let sig_tx = server.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);
})
};
loop {
let shutdown = *shutdown_rx.borrow();
if shutdown {
info!("shutdown signaled, stopping accept loop");
break;
}
let accept_result = tokio::select! {
result = acceptor.accept() => result,
_ = shutdown_rx.changed() => {
info!("shutdown signaled while waiting for connection");
break;
}
};
let (stream, info) = match accept_result {
Ok(conn) => conn,
Err(e) => {
error!("accept failed: {e}");
continue;
}
};
let remote_addr = info.remote_addr;
let handler_transport_kind = transport_kind;
let handler = ServerHandler::new(
Arc::clone(&server.auth_config),
server.outbound_proxy.clone(),
remote_addr,
handler_transport_kind,
Arc::clone(&server.connection_limiter),
server.max_auth_attempts,
);
if !handler.is_connection_allowed() {
continue;
}
let config = Arc::clone(&server.config);
let sessions = Arc::clone(&server.sessions);
let stealth = server.stealth;
let transport_is_tls = server.transport_mode == ServeTransportMode::Tls;
tokio::spawn(async move {
let result = handle_connection(
stream,
config,
handler,
sessions,
stealth,
transport_is_tls,
)
.await;
if let Err(e) = result {
warn!("connection error: {e}");
}
});
}
#[cfg(unix)]
signal_done.abort();
server.shutdown().await?;
Ok(())
}
}
async fn handle_connection<S>(
stream: S,
config: Arc<Config>,
handler: ServerHandler,
sessions: Arc<tokio::sync::Mutex<Vec<ActiveSession>>>,
stealth: bool,
transport_is_tls: bool,
) -> Result<(), anyhow::Error>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
if stealth && transport_is_tls {
let (protocol, mut reader) = stealth::detect_protocol(stream).await;
match protocol {
ProtocolDetection::Http => {
stealth::send_fake_nginx_404(&mut reader).await;
return Ok(());
}
ProtocolDetection::Ssh => {
let running = server::run_stream(config, reader, handler).await?;
let handle = running.handle();
let join = tokio::spawn(async {
let _ = running.await;
});
sessions.lock().await.push(ActiveSession { handle, join });
return Ok(());
}
}
}
let running = server::run_stream(config, stream, handler).await?;
let handle = running.handle();
let join = tokio::spawn(async {
let _ = running.await;
});
sessions.lock().await.push(ActiveSession { handle, join });
Ok(())
}
fn parse_proxy_config(proxy: Option<&str>) -> Option<ProxyConfig> {
proxy.map(|url| {
if url.starts_with("socks5://") {
let addr: SocketAddr = url
.strip_prefix("socks5://")
.unwrap()
.parse()
.expect("invalid socks5 proxy address");
ProxyConfig {
mode: ProxyMode::Socks5(addr),
}
} else if url.starts_with("http://") {
let addr: SocketAddr = url
.strip_prefix("http://")
.unwrap()
.parse()
.expect("invalid http connect proxy address");
ProxyConfig {
mode: ProxyMode::HttpConnect(addr),
}
} else {
panic!("unsupported proxy URL scheme: {url}");
}
})
}
#[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";
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 serve_options_default_fields() {
let opts = ServeOptions::new(make_key_source());
assert!(opts.authorized_keys.is_none());
assert!(opts.cert_authority.is_none());
assert_eq!(opts.transport_mode, ServeTransportMode::Tcp);
assert_eq!(opts.listen_addr, "0.0.0.0:22");
assert!(opts.tls_cert.is_none());
assert!(opts.tls_key.is_none());
assert!(opts.acme_domain.is_none());
assert!(!opts.stealth);
assert!(opts.proxy.is_none());
assert!(opts.iroh_relay.is_none());
assert_eq!(opts.max_connections_per_ip, 0);
assert_eq!(opts.max_auth_attempts, 10);
}
#[test]
fn serve_options_builder_pattern() {
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source())
.cert_authority(make_authorized_keys_source())
.transport_mode(ServeTransportMode::Tls)
.listen_addr("0.0.0.0:443")
.tls_cert("/etc/ssl/cert.pem")
.tls_key("/etc/ssl/key.pem")
.stealth(true)
.proxy("socks5://127.0.0.1:9050")
.iroh_relay("https://relay.example.com")
.max_connections_per_ip(5)
.max_auth_attempts(3);
assert!(opts.authorized_keys.is_some());
assert!(opts.cert_authority.is_some());
assert_eq!(opts.transport_mode, ServeTransportMode::Tls);
assert_eq!(opts.listen_addr, "0.0.0.0:443");
assert_eq!(opts.tls_cert.as_deref(), Some("/etc/ssl/cert.pem"));
assert_eq!(opts.tls_key.as_deref(), Some("/etc/ssl/key.pem"));
assert!(opts.stealth);
assert_eq!(opts.proxy.as_deref(), Some("socks5://127.0.0.1:9050"));
assert_eq!(
opts.iroh_relay.as_deref(),
Some("https://relay.example.com")
);
assert_eq!(opts.max_connections_per_ip, 5);
assert_eq!(opts.max_auth_attempts, 3);
}
#[test]
fn serve_options_validate_steam_without_tls_rejected() {
let opts = ServeOptions::new(make_key_source()).stealth(true);
assert!(opts.validate().is_err());
}
#[test]
fn serve_options_validate_stealth_with_tls_ok() {
let opts = ServeOptions::new(make_key_source())
.transport_mode(ServeTransportMode::Tls)
.tls_cert("/cert.pem")
.tls_key("/key.pem")
.stealth(true);
assert!(opts.validate().is_ok());
}
#[test]
fn serve_options_validate_tcp_no_tls_options_ok() {
let opts = ServeOptions::new(make_key_source());
assert!(opts.validate().is_ok());
}
#[test]
fn serve_options_validate_tls_requires_certs() {
let opts = ServeOptions::new(make_key_source()).transport_mode(ServeTransportMode::Tls);
assert!(opts.validate().is_err());
}
#[test]
fn serve_options_validate_tls_cert_without_key_rejected() {
let opts = ServeOptions::new(make_key_source())
.transport_mode(ServeTransportMode::Tls)
.tls_cert("/cert.pem");
assert!(opts.validate().is_err());
}
#[test]
fn serve_options_validate_tls_key_without_cert_rejected() {
let opts = ServeOptions::new(make_key_source())
.transport_mode(ServeTransportMode::Tls)
.tls_key("/key.pem");
assert!(opts.validate().is_err());
}
#[test]
fn serve_options_validate_tcp_with_acme_rejected() {
let opts =
ServeOptions::new(make_key_source()).acme_domain("example.com");
assert!(opts.validate().is_err());
}
#[test]
fn serve_options_validate_acme_domain_with_tls_ok() {
let opts = ServeOptions::new(make_key_source())
.transport_mode(ServeTransportMode::Tls)
.acme_domain("example.com");
assert!(opts.validate().is_ok());
}
#[test]
fn server_new_creates_server() {
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source());
let server = Server::new(opts).unwrap();
assert_eq!(server.max_auth_attempts, 10);
}
#[test]
fn server_new_stealth_without_tls_fails() {
let opts = ServeOptions::new(make_key_source()).stealth(true);
let result = Server::new(opts);
assert!(result.is_err());
}
#[test]
fn server_new_invalid_key_fails() {
let opts = ServeOptions::new(KeySource::Memory(b"not a key".to_vec()));
let result = Server::new(opts);
assert!(result.is_err());
}
#[test]
fn serve_transport_mode_display() {
assert_eq!(ServeTransportMode::Tcp.to_string(), "tcp");
assert_eq!(ServeTransportMode::Tls.to_string(), "tls");
assert_eq!(ServeTransportMode::Iroh.to_string(), "iroh");
}
#[test]
fn serve_transport_mode_equality() {
assert_eq!(ServeTransportMode::Tcp, ServeTransportMode::Tcp);
assert_ne!(ServeTransportMode::Tcp, ServeTransportMode::Tls);
assert_ne!(ServeTransportMode::Tls, ServeTransportMode::Iroh);
}
#[test]
fn serve_options_debug_redacts_keys() {
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source());
let debug_str = format!("{:?}", opts);
assert!(debug_str.contains("<KeySource>"));
assert!(!debug_str.contains("OPENSSH"));
}
#[test]
fn parse_proxy_config_socks5() {
let config = parse_proxy_config(Some("socks5://127.0.0.1:9050"));
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"));
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).is_none());
}
#[test]
fn serve_error_variants() {
assert_eq!(ServeError::AcceptFailed.to_string(), "accept failed");
}
#[test]
fn default_listen_addr() {
assert_eq!(DEFAULT_LISTEN_ADDR, "0.0.0.0:22");
}
#[test]
fn drain_timeout_is_two_seconds() {
assert_eq!(DRAIN_TIMEOUT, Duration::from_secs(2));
}
#[test]
fn server_shutdown_sender_clones() {
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source());
let server = Server::new(opts).unwrap();
let sender = server.shutdown_sender();
assert!(!server.is_shutdown());
let _ = sender.send(true);
assert!(server.is_shutdown());
}
#[test]
fn server_holds_listen_addr() {
let opts = ServeOptions::new(make_key_source())
.listen_addr("0.0.0.0:443");
let server = Server::new(opts).unwrap();
assert_eq!(server.listen_addr, "0.0.0.0:443");
}
#[tokio::test]
async fn integration_server_accept_loop_and_shutdown() {
use crate::transport::TcpAcceptor;
let acceptor = TcpAcceptor::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let opts = ServeOptions::new(make_key_source())
.authorized_keys(make_authorized_keys_source())
.listen_addr(acceptor.listen_addr().to_string());
let server = Server::new(opts).unwrap();
let shutdown_tx = server.shutdown_sender();
let server_handle = tokio::spawn(async move {
server
.run(acceptor, None)
.await
.expect("server run failed")
});
tokio::time::sleep(Duration::from_millis(50)).await;
let _ = shutdown_tx.send(true);
let result = tokio::time::timeout(Duration::from_secs(5), server_handle).await;
assert!(result.is_ok(), "server should have shut down within timeout");
}
}

View File

@@ -0,0 +1,226 @@
//! 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. 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 tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
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;
}
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);
}
}

View File

@@ -1,3 +1,10 @@
//! 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;

View File

@@ -9,7 +9,7 @@ use tokio::io;
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
const ALPN: &[u8] = b"wraith-ssh";
pub const ALPN: &[u8] = b"wraith-ssh";
const DEFAULT_RELAY_URL: &str = "https://relay.iroh.network/";
/// A client-side iroh QUIC P2P transport that connects to a remote iroh endpoint.
@@ -18,12 +18,21 @@ const DEFAULT_RELAY_URL: &str = "https://relay.iroh.network/";
/// 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 `wraith-ssh` ALPN and the provided
/// relay URL. Use this when wraith is the only iroh service on this node.
pub async fn new(
node_id: NodeId,
relay_url: Option<RelayUrl>,
@@ -40,7 +49,18 @@ impl IrohTransport {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self { node_id, endpoint })
Ok(Self { node_id, endpoint, owned: true })
}
/// Create an iroh transport using an existing shared endpoint.
///
/// The endpoint must already have the `wraith-ssh` ALPN registered
/// (typically via [`iroh::protocol::Router::builder`]). This enables
/// running wraith 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 {
@@ -50,6 +70,10 @@ impl IrohTransport {
pub fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
pub fn owned(&self) -> bool {
self.owned
}
}
#[async_trait]
@@ -73,11 +97,24 @@ impl Transport for IrohTransport {
/// (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 wraith-ssh ALPN must be registered
/// via an iroh `Router` that calls `Handler::accept()` on incoming
/// connections with the `wraith-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 `wraith-ssh` ALPN.
///
/// Use this when wraith is the only iroh service on this node.
pub async fn bind(
relay_url: Option<RelayUrl>,
proxy_url: Option<url::Url>,
@@ -93,7 +130,23 @@ impl IrohAcceptor {
builder = builder.proxy_url(proxy.clone());
}
let endpoint = builder.bind().await?;
Ok(Self { endpoint })
Ok(Self { endpoint, owned: true })
}
/// Create an iroh acceptor using an existing shared endpoint.
///
/// The endpoint must already have the `wraith-ssh` ALPN registered
/// (typically via [`iroh::protocol::Router::builder`]). When using a
/// shared endpoint, incoming connections with the `wraith-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 wraith 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 {
@@ -103,6 +156,10 @@ impl IrohAcceptor {
pub fn endpoint(&self) -> &Endpoint {
&self.endpoint
}
pub fn owned(&self) -> bool {
self.owned
}
}
#[async_trait]
@@ -140,6 +197,7 @@ mod tests {
assert!(!endpoint_id.is_empty());
let parsed = NodeId::from_z32(&endpoint_id);
assert!(parsed.is_ok());
assert!(acceptor.owned());
}
#[tokio::test]
@@ -147,6 +205,16 @@ mod tests {
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]
@@ -166,6 +234,20 @@ mod tests {
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]
@@ -202,4 +284,38 @@ mod tests {
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,10 +1,25 @@
//! Pluggable transport layer for Wraith.
//!
//! 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.
mod tcp;
#[cfg(feature = "iroh")]
mod iroh_transport;
pub use tcp::{TcpAcceptor, TcpTransport};
#[cfg(feature = "iroh")]
pub use iroh_transport::{IrohAcceptor, IrohTransport};
pub use iroh_transport::{IrohAcceptor, IrohTransport, ALPN as IROH_ALPN};
#[cfg(feature = "tls")]
mod tls;
@@ -24,19 +39,33 @@ 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)>;
}

View File

@@ -1,12 +1,23 @@
[package]
name = "wraith-napi"
version = "0.1.0"
edition = "2021"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Node.js native addon for Wraith via napi-rs: connect() and serve() SSH tunnel functions"
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
wraith-core = { path = "../wraith-core" }
napi = "3"
napi-derive = "3"
wraith-core = { path = "../wraith-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"
url = "2"
tracing = "0.1"

View File

@@ -0,0 +1,304 @@
//! NAPI `connect()` function and `WraithStream` 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 wraith_core::auth::client_auth::{ClientAuthConfig, ClientHandler};
use wraith_core::auth::keys::KeySource;
use wraith_core::transport::{IrohTransport, TcpTransport, TlsTransport, Transport};
const DEFAULT_HOST: &str = "wraith-control";
const DEFAULT_PORT: u32 = 0;
#[napi(object)]
pub struct WraithConnectOptions {
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 WraithStream {
read: Arc<Mutex<tokio::io::ReadHalf<russh::ChannelStream<client::Msg>>>>,
write: Arc<Mutex<tokio::io::WriteHalf<russh::ChannelStream<client::Msg>>>>,
}
#[napi]
impl WraithStream {
#[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: WraithConnectOptions) -> Result<WraithStream> {
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 = "wraith".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(WraithStream {
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,3 +1,29 @@
//! # wraith-napi
//!
//! Node.js native addon for [Wraith](https://git.alk.dev/alkdev/wraith) 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('wraith-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;
extern crate napi_derive;
mod connect;
mod serve;

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,29 @@
[package]
name = "wraith"
version = "0.1.0"
edition = "2021"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "CLI binary for Wraith: self-hostable SSH tunnel tool with pluggable transports"
repository.workspace = true
[[bin]]
name = "wraith"
path = "src/main.rs"
[features]
default = ["tls", "iroh"]
tls = ["wraith-core/tls", "dep:rustls-pemfile", "dep:rustls-pki-types"]
iroh = ["wraith-core/iroh", "dep:iroh", "dep:url"]
acme = ["wraith-core/acme", "dep:rustls-acme", "dep:rustls", "tls"]
[dependencies]
wraith-core = { path = "../wraith-core" }
clap = { version = "4", features = ["derive"] }
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
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 +1,548 @@
fn main() {}
//! # wraith
//!
//! CLI binary for [Wraith](https://git.alk.dev/alkdev/wraith), a self-hostable SSH-based tunnel
//! tool. Provides `wraith connect` (client) and `wraith serve` (server) subcommands with
//! pluggable transports (TCP, TLS, iroh).
//!
//! > **Alpha software.** See `wraith-core` for library usage.
use std::net::SocketAddr;
use std::process;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand, ValueEnum};
use wraith_core::auth::keys::KeySource;
use wraith_core::client::{ConnectOptions, TransportMode};
use wraith_core::server::{ServeOptions, ServeTransportMode, Server};
#[cfg(feature = "iroh")]
use wraith_core::transport::IrohTransport;
use wraith_core::transport::TcpTransport;
#[cfg(feature = "tls")]
use wraith_core::transport::TlsTransport;
use wraith_core::transport::Transport;
#[derive(Parser)]
#[command(name = "wraith", version, about = "Wraith SSH tunnel tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(
about = "Connect to a wraith server and start a SOCKS5 proxy / port forwarding session"
)]
Connect {
#[arg(
long,
help = "TCP/TLS server address (required for tcp/tls transport)",
env = "WRAITH_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 = "WRAITH_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 wraith 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 WRAITH_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 (wraith-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 (wraith-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<()> {
wraith_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 (wraith 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 = wraith_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 (wraith-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(
wraith_core::transport::AcmeCertProvider::domain(domain)
.with_production_directory(),
);
let acceptor =
wraith_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 = wraith_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 (wraith-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 = wraith_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,13 +1,16 @@
---
status: reviewed
last_updated: 2026-06-02
status: draft
last_updated: 2026-06-04
---
# Wraith Architecture
## Current State
Architecture specification reviewed and ready for implementation. All open questions resolved. 19 ADRs accepted.
Architecture specification in active development. 22 ADRs accepted. Unified
auth and call protocol architecture being specified — see [auth.md](auth.md)
and [call-protocol.md](call-protocol.md). Configuration architecture under
exploration — see [research/configuration.md](../research/configuration.md).
## Architecture Documents
@@ -15,11 +18,19 @@ Architecture specification reviewed and ready for implementation. All open quest
|----------|--------|-------------|
| [overview.md](overview.md) | reviewed | Package purpose, exports, dependencies |
| [transport.md](transport.md) | reviewed | Transport abstraction: TCP, TLS, iroh |
| [auth.md](auth.md) | draft | Unified auth: SSH + token, IdentityProvider trait |
| [call-protocol.md](call-protocol.md) | draft | Bidirectional call/event protocol, operation registry |
| [client.md](client.md) | reviewed | Client connection, SOCKS5, port forwarding |
| [server.md](server.md) | reviewed | Server acceptance, channel handling, proxy |
| [tun-shim.md](tun-shim.md) | deprecated | TUN interface wrapper — **deferred**, use tun2proxy |
| [napi-and-pubsub.md](napi-and-pubsub.md) | reviewed | NAPI wrapper and pubsub event target adapter |
## Research Documents
| Document | Status | Description |
|----------|--------|-------------|
| [configuration.md](../research/configuration.md) | draft | Configuration architecture: static/dynamic split, hot reload, forwarding policy |
## ADR Table
| ADR | Title | Status |
@@ -43,10 +54,15 @@ Architecture specification reviewed and ready for implementation. All open quest
| [017](decisions/017-stealth-mode-protocol-multiplexing.md) | Stealth mode — protocol multiplexing on port 443 | Accepted |
| [018](decisions/018-control-channel-for-pubsub.md) | Control channel for pubsub over SSH | Accepted |
| [019](decisions/019-proxy-dual-semantics.md) | `--proxy` dual semantics (client vs server) | Accepted |
| [023](decisions/023-unified-auth-shared-key-material.md) | Unified auth with shared key material + token auth | Accepted |
| [024](decisions/024-bidirectional-call-protocol.md) | Bidirectional call protocol (EventEnvelope) | Accepted |
| [025](decisions/025-handler-spec-separation.md) | Handler/spec separation for downstream service registration | Accepted |
## Open Questions
All open questions have been resolved. See [open-questions.md](open-questions.md) for details on each resolution.
Most open questions have been resolved. Open questions remain for
configuration, auth, and call protocol — see
[open-questions.md](open-questions.md) for details.
## Lifecycle Definitions

261
docs/architecture/auth.md Normal file
View File

@@ -0,0 +1,261 @@
---
status: draft
last_updated: 2026-06-04
---
# Authentication & Identity
## What
A unified authentication and identity 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
Wraith 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.
## Architecture
### Auth Presentation Per Transport
| Transport | Auth presentation | Verification |
|-----------|-------------------|-------------|
| SSH (TCP, TLS, iroh) | SSH public key auth in the SSH handshake | `ServerAuthConfig::authenticate_publickey()` — key lookup in authorized set |
| WebTransport (HTTP/3) | Signed timestamp token in CONNECT request | Token auth — same authorized set verifies the Ed25519 signature |
| Future (WebSocket, etc.) | Signed timestamp token in headers/query | Same token verification |
The **key material is shared**. The **presentation differs per transport**. The
**verification result is the same**: an authenticated identity with scopes.
### 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/wraith?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 Trait
The `IdentityProvider` trait decouples wraith-core from any specific identity
storage. It resolves a key fingerprint or auth token to an `Identity` with
scopes and resources.
```rust
pub trait IdentityProvider: Send + Sync + 'static {
/// Resolve an SSH public key fingerprint to an identity.
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
/// Resolve an auth token to an identity.
/// Returns None if the token is invalid, expired, or the key is not authorized.
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
}
pub struct Identity {
pub id: String, // Unique identifier — fingerprint (config) or account UUID (database)
pub scopes: Vec<String>, // e.g., ["relay:connect", "service:gitea:read"]
pub resources: HashMap<String, Vec<String>>, // e.g., {"service": ["gitea", "registry"]}
}
```
**Default implementation**: `ConfigIdentityProvider` loads from
`DynamicConfig.auth` (the `authorized_keys` set). Every authorized key gets a
default scope set. No database required.
**Hub implementation**: Backed by `@alkdev/storage`'s `peer_credentials` and
`accounts` tables plus the ACL graph. Resolves fingerprint → account →
organization membership → effective scopes. Uses `ArcSwap` for hot reload.
The trait is the contract. The backing store is pluggable. Wraith-core never
depends on Honker, SQLite, or any specific database.
### AuthPolicy Structure
`AuthPolicy` in `DynamicConfig` holds both auth paths, sharing key material:
```rust
pub struct AuthPolicy {
pub ssh: SshAuthConfig,
pub token: TokenAuthConfig,
}
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>),
}
```
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., `/wraith?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 wraith-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.
### 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**: Should `Identity.scopes` be populated from `ForwardingPolicy`
rules, from an external `IdentityProvider`, or from both? 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? 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 |
## References
- [server.md](server.md) — Current SSH auth handler
- [transport.md](transport.md) — Transport abstraction
- [configuration.md](../research/configuration.md) — DynamicConfig, AuthPolicy structure
- [open-questions.md](open-questions.md) — OQ-17 (resolved), OQ-18, OQ-19
- `server/handler.rs` — Current `auth_publickey()` callback
- `auth/server_auth.rs` — Current `ServerAuthConfig` struct
- `auth/keys.rs` — `KeySource` and key loading
- [wtransport](https://github.com/BiagioFesta/wtransport) — Rust WebTransport library
- [WebTransport W3C Spec](https://www.w3.org/TR/webtransport/) — Browser API
- [@alkdev/storage](/workspace/@alkdev/storage) — `peer_credentials` table, ACL graph

View File

@@ -0,0 +1,402 @@
---
status: draft
last_updated: 2026-06-04
---
# 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, hub/spoke) register their own operations without modifying core.
## 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 hub/spoke model where
spokes expose operations the hub invokes.
## Architecture
### Operation Paths
Operation names use slash-based paths aligned with URL routing conventions:
```
/{spoke}/{service}/{op}
```
- **spoke** — identity prefix of the node that exposes the operation. The hub
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` | Spoke `dev1`, service `fs`, operation `readFile` |
| `/dev1/bash/exec` | Spoke `dev1`, service `bash`, operation `exec` |
| `/hub/agent/chat` | Hub's own `agent` service, operation `chat` |
| `/hub/sessions/list` | Hub's own `sessions` service, operation `list` |
| `/browser-1/notify/alert` | Browser spoke `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 hub routes calls to spokes
using the first path segment:
```
Hub (server) Spoke: "dev1" (client)
│ │
│ call.requested │
│ name: "/dev1/fs/readFile" │
│ payload: { path: "/src/main.rs" } │
│──────────────────────────────────────────▶│
│ │
│ call.responded │
│ id: <requestId> │
│ payload: { content: "fn main()..." } │
│◀──────────────────────────────────────────│
│ │
│ Spoke exposes /dev1/fs/*, │
│ /dev1/bash/* to hub │
│ │
│◀─ call.requested ────────────────────────│
│ name: "/hub/agent/chat" │
│ payload: { provider: "anthropic", ... } │
│ │
│── call.responded ──────────────────────▶ │
│ id: <requestId> │
│ payload: { completion: "..." } │
```
The hub's registry includes:
- **Hub-local operations** (`/hub/*`) — handled directly
- **Remote operations** (`/{spoke}/*`) — forwarded to the spoke connection
When the hub routes `/dev1/fs/readFile` to spoke `dev1`, it strips the spoke
prefix and delivers the call to the spoke's local registry as `/fs/readFile`.
The spoke doesn't need to know its own alias.
### Hub/Spoke Architecture
```
┌─────────────────────────────────┐
│ Hub │
│ │
│ Hub-local services: │
│ /hub/agent/chat (LLM coord) │
│ /hub/agent/complete │
│ /hub/sessions/list │
│ /hub/sessions/history │
│ │
│ Spoke registry (discovered): │
│ /dev1/fs/* → dev1 connection │
│ /dev1/bash/* → dev1 connection │
│ /dev2/fs/* → dev2 connection │
│ /browser-1/notify/* → WT conn │
└──────┬───────┬───────┬──────────┘
│ │ │
┌─────────▼┐ ┌───▼────┐ ┌▼───────────┐
│ Dev Spoke│ │Dev Spk │ │Browser Spoke│
│ "dev1" │ │"dev2" │ │"browser-1" │
│ /fs/* │ │/fs/* │ │/notify/* │
│ /bash/* │ │/bash/* │ │ │
│ /search/*│ │ │ │ │
└───────────┘ └────────┘ └─────────────┘
```
When a spoke connects, it registers its operations with the hub:
```
spoke → hub: call.requested { name: "/hub/services/register", payload: {
spoke: "dev1",
operations: ["/fs/readFile", "/fs/writeFile", "/bash/exec", "/search/query"]
}}
```
The hub adds these to its routing table with the spoke prefix. Other spokes
and browser clients can then call `/dev1/fs/readFile` without knowing how
the hub 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 spoke 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 spoke prefix
(`/services/list`, `/services/schema`). They live on whatever node the
caller is connected to. Spoke-prefixed operations (`/dev1/fs/readFile`)
are routed by the hub.
### ACL Per Operation Path
Access control maps to path prefixes using standard URL-like matching:
| Pattern | Matches | Purpose |
|---------|---------|---------|
| `/dev1/*` | All operations on spoke `dev1` | Full access to a spoke |
| `/*/fs/*` | `fs` service on any spoke | Read file access across dev envs |
| `/*/bash/*` | `bash` service on any spoke | Shell access (higher risk) |
| `/hub/agent/*` | Hub LLM agent | LLM calls |
| `/hub/sessions/*` | Hub session management | Session history |
| `/browser-1/notify/alert` | Specific operation on specific spoke | 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 by design. It maps to any transport
that carries `EventEnvelope` frames:
| 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.
### 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-wraith` 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
The hub commonly runs an agent service that coordinates between LLM providers
and tool calls. This service is just another set of registered operations —
no special treatment:
- `/hub/agent/chat` — send a message, get a completion. Routes to the
appropriate LLM provider based on available spokes and configuration.
- `/hub/agent/complete` — streaming completion. Yields tokens as they arrive.
- `/hub/sessions/list` — list session histories (backed by Honker or other
durable storage).
- `/hub/sessions/history` — retrieve a specific session's message history.
The agent service uses the same call protocol to invoke tools on spokes:
`/dev1/fs/readFile` for file access, `/dev1/bash/exec` for shell commands. It
stores session state via whatever mechanism the hub deployment provides — core
doesn't mandate Honker or any specific storage.
## 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 spoke 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 spoke that exposes `/dev1/bash/exec` can restrict access via
`required_scopes` — not every authenticated identity should have shell access.
## Open Questions
- **OQ-20**: How does the hub track which spokes expose which operations when
spokes 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), or is client→server always a single request payload with
streaming only server→client? See [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 |
## 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
- [configuration.md](../research/configuration.md) — ForwardingPolicy, service metadata
- `@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

@@ -0,0 +1,85 @@
# ADR-023: Unified Authentication with Shared Key Material
## Status
Accepted
## Context
Wraith currently authenticates connections exclusively through SSH public key
auth in the SSH handshake. This works for SSH-over-any-transport (TCP, TLS,
iroh) because SSH carries its own auth protocol. But WebTransport and other
HTTP-level transports cannot perform SSH key exchange — browsers speak HTTP/3,
not SSH.
Without unification, non-SSH transports would need a completely separate
identity system (API keys, JWTs, session tokens). This creates two problems:
(1) operators manage two key sets with two rotation mechanisms, and (2) the
same person connecting via SSH and WebTransport appears as two different
identities.
The `IdentityProvider` trait is needed to decouple wraith-core from any
specific identity storage (config file vs. database). Without it, wraith-core
would either hardcode config-file-based auth or take a database dependency —
neither is acceptable for a library crate.
## Decision
**Unified authentication**: The same Ed25519 key material (`authorized_keys`
and `cert_authorities`) is shared across both SSH auth and token auth. The
presentation differs per transport, but the verification result (an
`Identity` with scopes) is the same.
**Token auth for non-SSH transports**: WebTransport clients present a signed
timestamp token in the CONNECT request URL:
```
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)
```
Server extracts the fingerprint, looks it up in the same `authorized_keys`
set, verifies the signature, and checks the timestamp window (default ±300s).
**`IdentityProvider` trait**: Decouples wraith-core from identity storage. The
trait resolves a fingerprint or token to an `Identity`. Default implementation
loads from `DynamicConfig.auth` (no database). Hub implementation can back it
with `@alkdev/storage`.
**`TokenKeySource::Shared`**: The token auth uses the same authorized keys set
as SSH auth by default. Deployments that want separate access control can use
`TokenKeySource::Separate` with a distinct key set.
**Replay protection via timestamps**: V1 uses timestamp-only (no server state).
Zero-replay can be added later via a nonce challenge-response without changing
the key material.
## Consequences
- **Positive**: One key set, one rotation, one `reloadAuth()` call. Adding a
key to `authorized_keys` immediately grants access via both SSH and
WebTransport.
- **Positive**: `IdentityProvider` trait makes wraith-core independent of any
specific database. Default: config file. Hub: `@alkdev/storage`.
- **Positive**: Browser clients can authenticate using Ed25519 keys via
SubtleCrypto (Chrome 105+, Firefox 130+, Safari 17+). Deno supports it
natively.
- **Positive**: No JWT library dependency. The token is a simple Ed25519
signature over a fixed structure — same primitives SSH already uses.
- **Negative**: V1 has a replay window (±300s). An attacker who intercepts a
QUIC packet can replay the token within the window. Acceptable because QUIC
interception is the same threat level as connection hijacking.
- **Negative**: Certificate authority tokens are not supported in v1. CA
verification requires the full OpenSSH certificate structure, which doesn't
fit in a signed timestamp.
- **Negative**: Browser-side key management is less ergonomic than SSH key
files. The private key must be imported into SubtleCrypto. This is a UI/UX
concern, not a protocol concern.
## References
- [auth.md](../auth.md) — Full auth architecture spec
- [ADR-012](012-auth-ed25519-and-cert-authority.md) — Ed25519 + cert-authority auth
- [OQ-17](../open-questions.md) — Transport-aware auth (resolved by this ADR)
- [configuration.md](../../research/configuration.md) — OQ-CFG-04, OQ-CFG-06 (resolved)

View File

@@ -0,0 +1,63 @@
# ADR-024: Bidirectional Call Protocol
## Status
Accepted
## Context
The wraith control channel (ADR-018) routes from client → server's event bus.
This is unidirectional: clients can send events to the server, but the server
cannot call operations on the client. In the hub/spoke model, spokes (dev env
containers) connect to a hub and expose operations (fs, bash, search) that the
hub invokes. The hub needs to call *spoke* operations.
Additionally, the current control channel provides no request/response semantics.
Every consumer that needs call/response reinvents the pending-request correlation.
## Decision
The call protocol is bidirectional. Both sides can send `call.requested` and
receive `call.responded`. The protocol uses `EventEnvelope` wire format (4-byte
BE length prefix + JSON) — the same as `@alkdev/pubsub`.
Five event types: `call.requested`, `call.responded`, `call.completed`,
`call.aborted`, `call.error`.
A call is a subscribe that resolves after one event. Both use `call.requested`
with correlated `requestId`. `PendingRequestMap` in core provides correlation.
Operation names use slash-based paths: `/{spoke}/{service}/{op}`. The first
path segment routes the call to the correct connected node. The hub's registry
maps spoke prefixes to connections. This mirrors iroh's ALPN dispatch: the
first segment is the routing key, remaining path dispatches within the node.
Core-provided operations use short paths without a spoke prefix
(`/services/list`, `/services/schema`). Spoke operations are prefixed
(`/dev1/fs/readFile`).
This generalizes ADR-018's control channel: the `wraith-*` destination becomes
a transport for `EventEnvelope` frames with call protocol semantics, instead of
raw pubsub dispatch.
## Consequences
- **Positive**: Hub can invoke operations on spokes. Dev env containers
expose fs, bash, search — the hub calls them as needed.
- **Positive**: Browser clients can expose custom UDFs. Any connected participant
can both call and serve operations.
- **Positive**: Built-in request/response correlation. One `PendingRequestMap`
in core serves all consumers.
- **Positive**: Slash-based paths align with URL routing, OpenAPI, MCP, and
iroh's ALPN dispatch. First segment = routing key.
- **Positive**: Multiple spokes exposing the same service (two dev envs both
exposing `/fs/*`) are naturally differentiated by the spoke prefix.
- **Negative**: The `PendingRequestMap` adds in-memory state. Entries must be
cleaned up on timeout or connection close.
- **Negative**: The hub must maintain a routing table mapping spoke identities
to connections, with registration on connect and cleanup on disconnect.
## References
- [call-protocol.md](../call-protocol.md) — Full call protocol spec
- [ADR-018](018-control-channel-for-pubsub.md) — Control channel (generalized)
- [napi-and-pubsub.md](../napi-and-pubsub.md) — NAPI wrapper and pubsub adapter

View File

@@ -0,0 +1,73 @@
# ADR-025: Handler/Spec Separation for Downstream Service Registration
## Status
Accepted
## Context
The current control channel (ADR-018) is hardcoded: `wraith-control:0` bridges
to the local pubsub event bus. If NAPI wants to expose `fs.readFile` or
`bash.exec` as callable operations, it has no way to register these with core's
channel routing. The NAPI handler would need to intercept channel data outside
of core.
For the hub/spoke model, spokes register their operations with the hub when
they connect. The hub's registry must include both hub-local operations and
remote operations exposed by spokes.
## Decision
Operation specs and handlers are separated from core. Core provides:
1. `OperationSpec` — describes what an operation does (name, type, input/output
schemas, access control)
2. `OperationHandler` — implements the operation logic
3. `OperationRegistry` — maps paths to specs + handlers
4. Built-in operations: `/services/list`, `/services/schema`
Downstream consumers register their own operations:
```rust
// NAPI layer registers dev env tools
registry.register(OperationSpec { name: "/fs/readFile", ... }, fs_read_handler);
registry.register(OperationSpec { name: "/bash/exec", ... }, bash_exec_handler);
// Browser client registers a custom UDF
registry.register(OperationSpec { name: "/notify/alert", ... }, notify_handler);
```
Operation names use slash-based paths: `/{spoke}/{service}/{op}`. The first
segment routes to the node. The `namespace` field on `OperationSpec` is
derived from the second path segment (`service`).
When spoke operations are registered with the hub, the hub adds the spoke
prefix: a spoke that registers `/fs/readFile` as "dev1" becomes addressable as
`/dev1/fs/readFile` in the hub's routing table.
The `/services/list` operation returns all registered specs. The
`/services/schema` operation returns the spec for a specific operation. These
are read-only — no admin operations.
## Consequences
- **Positive**: NAPI, Python, and any downstream consumer can register
operations without modifying core.
- **Positive**: Service discovery is built in. Clients query `/services/list`
to learn what operations a hub offers.
- **Positive**: Spoke prefix naturally differentiates multiple spokes exposing
the same service (dev1 vs dev2).
- **Positive**: `AccessControl` on each `OperationSpec` enables per-operation
authorization. Higher-risk operations (shell, filesystem write) can require
tighter scopes.
- **Positive**: Schema exposure enables MCP adapter generation. OperationSpec
maps directly to MCP tool definitions.
- **Negative**: The registry adds complexity. Core now owns `OperationSpec`,
`OperationRegistry`, and `PendingRequestMap`.
- **Negative**: Namespace collisions between downstream consumers are possible.
The spoke prefix mitigates this: `/dev1/fs/readFile` vs `/dev2/fs/readFile`.
## References
- [call-protocol.md](../call-protocol.md) — Full call protocol spec
- [ADR-018](018-control-channel-for-pubsub.md) — Control channel (generalized)
- `@alkdev/operations` — TypeScript `OperationSpec`, `CallHandler`, registry

View File

@@ -1,6 +1,6 @@
---
status: reviewed
last_updated: 2026-06-02
status: draft
last_updated: 2026-06-04
---
# Open Questions
@@ -90,4 +90,87 @@ last_updated: 2026-06-02
- **Status**: ~~resolved~~
- **Priority**: ~~low~~
- **Resolution**: ADR-015 — Use napi-rs. It's the standard for Node.js native addons, matches our primary consumer (TypeScript/Node.js), and has the best ecosystem and documentation. If future Python or mobile consumers are needed, a separate uniffi layer can be added — the Rust core doesn't change.
- **Cross-references**: [ADR-015](decisions/015-napi-rs-for-ffi-bridge.md), napi-and-pubsub.md
- **Cross-references**: [ADR-015](decisions/015-napi-rs-for-ffi-bridge.md), napi-and-pubsub.md
## Configuration
### OQ-12: Per-user forwarding scope vs global rules
- **Origin**: [research/configuration.md](../research/configuration.md)
- **Status**: open
- **Priority**: medium
- **Resolution**: (pending)
- **Cross-references**: configuration.md
### OQ-13: Config file auto-reload via file watching
- **Origin**: [research/configuration.md](../research/configuration.md)
- **Status**: resolved
- **Priority**: low
- **Resolution**: No file watching. CLI loads once at startup; NAPI/hub reload explicitly. File watching is a potential attack vector and unnecessary complexity for a security tool.
- **Cross-references**: configuration.md
### OQ-14: ArcSwap vs RwLock for dynamic config
- **Origin**: [research/configuration.md](../research/configuration.md)
- **Status**: resolved
- **Priority**: low
- **Resolution**: ArcSwap. Lock-free reads on the hot path (every auth check, every channel open). `RwLock` adds contention. `arc-swap` is small (~500 lines) and well-maintained.
- **Cross-references**: configuration.md
### OQ-15: TLS + WebTransport + iroh QUIC listener coexistence
- **Origin**: [research/configuration.md](../research/configuration.md)
- **Status**: open
- **Priority**: medium
- **Resolution**: (pending — needs R&D in WebTransport transport session)
- **Cross-references**: [auth.md](auth.md), OQ-19
### OQ-16: Transport-specific forwarding policy (e.g., WebTransport clients restricted to wraith-* channels)
- **Origin**: [research/configuration.md](../research/configuration.md)
- **Status**: open
- **Priority**: low
- **Resolution**: (pending — defer to forwarding policy design)
- **Cross-references**: configuration.md
### OQ-17: Transport-aware auth layer (SSH keys vs API keys for non-SSH transports)
- **Origin**: [research/configuration.md](../research/configuration.md)
- **Status**: ~~resolved~~
- **Priority**: ~~medium~~
- **Resolution**: ADR-023 — Unified auth with shared key material. SSH transports use SSH pubkey auth. Non-SSH transports (WebTransport) use Ed25519-signed timestamp tokens. Both verify against the same `authorized_keys` set. The presentation differs per transport, but the identity is unified. `AuthPolicy` holds both `SshAuthConfig` and `TokenAuthConfig`, with `TokenKeySource::Shared` as the default (same keys for both paths). `IdentityProvider` trait decouples wraith-core from identity storage.
- **Cross-references**: [ADR-023](decisions/023-unified-auth-shared-key-material.md), [auth.md](auth.md), OQ-15
## Auth
### OQ-18: Source of Identity.scopes — ForwardingPolicy, IdentityProvider, or both?
- **Origin**: [auth.md](auth.md)
- **Status**: open
- **Priority**: medium
- **Resolution**: (pending)
- **Cross-references**: ADR-023, [call-protocol.md](call-protocol.md)
### OQ-19: Separate TLS identity for WebTransport vs shared with SSH-over-TLS?
- **Origin**: [auth.md](auth.md)
- **Status**: open
- **Priority**: low
- **Resolution**: (pending)
- **Cross-references**: OQ-15
## Call Protocol
### OQ-20: Spoke registration and discovery on connect/disconnect
- **Origin**: [call-protocol.md](call-protocol.md)
- **Status**: open
- **Priority**: medium
- **Resolution**: (pending — registration on connect / cleanup on disconnect is the leading approach)
- **Cross-references**: ADR-024, ADR-025
### OQ-21: Routing calls to specific spokes with same-service operations
- **Origin**: [call-protocol.md](call-protocol.md)
- **Status**: ~~resolved~~
- **Priority**: ~~medium~~
- **Resolution**: ADR-024, ADR-025 — Operation paths use `/{spoke}/{service}/{op}` format. The first path segment identifies the spoke and routes the call to the correct connected node. Multiple spokes exposing the same service (e.g., two dev envs both with `/fs/*`) are differentiated by the spoke prefix (`/dev1/fs/readFile` vs `/dev2/fs/readFile`). The hub maintains a routing table mapping spoke identity to connection. This mirrors iroh's ALPN dispatch: first segment = routing key.
- **Cross-references**: [call-protocol.md](call-protocol.md), ADR-024, ADR-025
### OQ-22: Client streaming (streaming inputs) in the call protocol?
- **Origin**: [call-protocol.md](call-protocol.md)
- **Status**: open
- **Priority**: low
- **Resolution**: (pending)
- **Cross-references**: ADR-024

View File

@@ -0,0 +1,588 @@
---
status: draft
last_updated: 2026-06-04
phase: exploration
---
# Configuration Architecture
## Problem
Wraith's configuration is loaded once at startup and never changes. This has
three specific failures:
1. **No hot reload of authentication credentials.** Adding or removing an
authorized key requires restarting the server process. In a hub/spoke
deployment where keys are managed via a database (see
`@alkdev/storage`'s `peer_credentials` table), the wraith process must be
restarted every time a key is added, revoked, or rotated. This is
operationally unacceptable for a production service.
2. **No port forwarding access control.** Any authenticated client can open a
`direct-tcpip` channel to any destination. There is no policy governing
which hosts, ports, or `wraith-*` control channels a client may access. This
is a security gap — a compromised key grants unrestricted network access
through the tunnel.
3. **No structured configuration beyond CLI flags.** ADR-011 chose
programmatic-first configuration for the alpha. This was correct — it
avoided cross-platform path issues and kept the API surface small. But as
wraith moves toward publishable releases, operators need config files for
reproducible deployments, and the NAPI layer needs programmatic reload
capability that the current `ServeOptions` builder pattern doesn't support.
### What's Not The Problem
- This does not propose depending on Honker, SQLite, or any specific data
source at the `wraith-core` level. The core provides a reload mechanism;
data sources plug in from outside.
- This does not propose file-watching (potential attack vector, unnecessary
complexity). CLI usage loads config once at startup. Programmatic usage
(NAPI, hub) calls reload explicitly.
- This does not replace the existing `ServeOptions` builder pattern. It
generalizes it.
## Analysis
### Static vs Dynamic Configuration
Not all configuration should be reloadable. Transport-level settings (listen
address, TLS certificates, host key) require socket/TLS renegotiation to change
at runtime — effectively a restart. Auth and forwarding policy can change
atomically without disrupting existing connections.
| Category | Examples | Reloadable? |
|---|---|---|
| Transport | listen addr, TLS cert/key, iroh relay, stealth mode | No — requires bind change |
| Identity | host key, host key algorithm | No — requires SSH re-negotiation |
| Auth | authorized keys, cert authorities | **Yes** — next auth check picks up changes |
| Forwarding | allowed destinations, per-principal rules | **Yes** — next channel open picks up changes |
| Rate limits | max connections per IP, max auth attempts | **Yes** — next check picks up changes |
The split is clean: anything that affects the SSH handshake or socket binding
is static. Anything that's checked per-connection or per-channel is dynamic.
### Current Architecture
```
ServeOptions (builder) → Server::new()
├─ Arc<server::Config> (russh config, immutable)
├─ Arc<ServerAuthConfig> (keys + CAs, immutable after load)
├─ Arc<ConnectionRateLimiter> (mutable but not reloadable)
└─ ServerHandler::new(auth_config, ...)
ServerHandler
├─ auth_config: Arc<ServerAuthConfig> ← shared, immutable
├─ connection_limiter: Arc<ConnectionRateLimiter>
├─ outbound_proxy: Option<ProxyConfig>
└─ (no forwarding policy field)
```
`auth_publickey()` reads from `self.auth_config` via `Arc` dereference. No
path to update it.
### Proposed Architecture
Replace `Arc<ServerAuthConfig>` with a reloadable provider:
```
StaticConfig (Arc, loaded once)
├─ transport mode, listen addr, TLS config, iroh config
├─ stealth, proxy
├─ host key
└─ max_auth_attempts, max_connections_per_ip
DynamicConfig (Arc<ArcSwap<DynamicConfig>>, reloadable)
├─ auth: ServerAuthConfig
├─ forwarding: ForwardingPolicy
└─ rate_limits: RateLimitConfig
ConfigReloadHandle (exposed to NAPI)
└─ reload(DynamicConfig)
```
`ArcSwap` provides lock-free reads on the hot path. Every `auth_publickey()`
and `channel_open_direct_tcpip()` call does an `Arc` dereference — zero cost
compared to the current approach. Writes are atomic: `store()` swaps the
pointer. Existing connections finish with their current config, new connections
get the new config.
### Forwarding Policy
Currently, `channel_open_direct_tcpip` in `handler.rs` spawns a proxy task for
any destination. The only gate is authentication. A forwarding policy adds a
check before the proxy spawn:
```rust
pub struct ForwardingPolicy {
default: ForwardingAction,
rules: Vec<ForwardingRule>,
}
pub struct ForwardingRule {
target: TargetPattern,
action: ForwardingAction,
principals: Vec<String>,
}
pub enum ForwardingAction { Allow, Deny }
pub enum TargetPattern {
Any,
Host(String),
Cidr(IpNetwork),
PortRange(String, Range<u16>),
WraithPrefix,
}
```
Rule evaluation: first match wins, default applies if no rule matches. This
model maps to OpenSSH's `AllowTcpForwarding` + `PermitOpen` but is more
expressive. It also maps to `peer_credentials.metadata.scopes` in `@alkdev/storage`
— the hub can generate forwarding rules from stored scopes.
Rule ordering matters. A deny-then-allow pattern gives blocklist semantics. An
allow-then-deny pattern gives allowlist semantics. Both are useful. The
default determines the fallback.
### Configuration File Format
ADR-011 chose "programmatic-first, no config file." This was correct for alpha.
For publishable releases, a config file enables:
- Reproducible deployments (version-controlled config)
- Less verbose CLI invocations
- Separate files for static and dynamic config (only static needs to be in the
config file; dynamic comes from the reload mechanism)
TOML is the idiomatic Rust choice. The config file covers static config only —
the same fields as `ServeOptions`. Dynamic config (auth, forwarding) comes from
the reload mechanism, not from the file. This preserves ADR-011's intent: the
core doesn't know about the data source for auth keys, it just provides a place
to put them.
```toml
[server]
transport = "tls"
listen = "0.0.0.0:443"
stealth = false
max_connections_per_ip = 5
max_auth_attempts = 3
[server.tls]
cert = "/etc/wraith/tls/cert.pem"
key = "/etc/wraith/tls/key.pem"
[server.iroh]
relay = "https://relay.alk.dev"
[auth]
host_key = "/etc/wraith/ssh/host_key"
[forwarding]
default = "deny"
[[forwarding.rules]]
target = "localhost:*"
action = "allow"
[[forwarding.rules]]
target = "wraith-*"
action = "allow"
[[forwarding.rules]]
target = "*:22"
action = "deny"
```
The `[[forwarding.rules]]` array syntax is TOML's array-of-tables pattern.
Rules are evaluated in order; first match wins.
### NAPI Reload API
The NAPI layer exposes the reload handle:
```typescript
interface WraithServer {
reloadAuth(auth: { authorizedKeys?: Buffer, certAuthority?: Buffer }): void;
reloadForwarding(policy: ForwardingPolicyConfig): void;
reloadAll(config: DynamicConfig): void;
}
interface ForwardingPolicyConfig {
default: 'allow' | 'deny';
rules: ForwardingRuleConfig[];
}
interface ForwardingRuleConfig {
target: string; // "localhost:*", "10.0.0.0/8:80", "wraith-*"
action: 'allow' | 'deny';
principals?: string[]; // default ["*"]
}
```
The hub calls `server.reloadAuth(...)` after writing to `peer_credentials`.
The NAPI layer parses the key data and constructs a new `DynamicConfig`, then
calls the `ConfigReloadHandle`.
### Client Configuration
Client configuration is almost entirely static (which server to connect to,
which key to use). The only potential dynamic config is key rotation, which is
less urgent because clients don't serve. For now, client configuration stays
as `ConnectOptions` — no `ArcSwap` needed.
A config file for client connections could define named profiles:
```toml
[profiles.production]
server = "hub.alk.dev:443"
transport = "tls"
identity = "/home/user/.ssh/id_ed25519"
[profiles.staging]
server = "staging.alk.dev:22"
transport = "tcp"
identity = "/home/user/.ssh/staging_key"
```
This is a convenience layer on top of `ConnectOptions`, not a replacement.
### 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 | `ArcSwap<DynamicConfig>` | `ConfigReloadHandle::reload()` |
| NAPI | `serve()` options | Same `ArcSwap` | `server.reloadAuth()`, `server.reloadForwarding()` |
The CLI doesn't need a reload mechanism. When you're running wraith from the
command line, restarting is fine. The reload mechanism exists for programmatic
consumers that manage credentials in a database.
### Multi-Transport Listeners
A host may want to accept connections on multiple transports simultaneously:
- TCP on port 22 (simple, direct SSH)
- TLS on port 443 (stealth mode, corporate firewalls)
- iroh QUIC (P2P, no port forwarding needed)
- WebTransport on port 443 (browser clients, shares the HTTP/3 listener)
Currently `ServeTransportMode` is a single enum and `Server::run()` takes one
acceptor. To serve multiple transports, the architecture needs to change.
**Option A: `Server` manages multiple listeners internally.**
```rust
pub struct Server {
// Shared state (one copy, shared across all listeners)
config: Arc<server::Config>,
dynamic_config: Arc<ArcSwap<DynamicConfig>>,
connection_limiter: Arc<ConnectionRateLimiter>,
outbound_proxy: Option<ProxyConfig>,
sessions: Arc<tokio::sync::Mutex<Vec<ActiveSession>>>,
shutdown_tx: tokio::sync::watch::Sender<bool>,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
// Per-listener state
listeners: Vec<ListenerConfig>,
}
pub struct ListenerConfig {
transport: ServeTransportMode,
listen_addr: SocketAddr,
stealth: bool,
// Transport-specific config (TLS cert, iroh relay, etc.)
tls: Option<TlsConfig>,
iroh: Option<IrohConfig>,
}
```
`Server::run()` spawns one accept loop per `ListenerConfig`. Each loop
constructs its own acceptor and `ServerHandler` (with the appropriate
`TransportKind` tag), but shares the auth config, connection limiter, and
session list. Shutdown signal goes to all loops.
**Option B: Caller manages multiple `Server` instances.**
The caller creates N `Server` objects, each with its own transport. They share
`Arc<ArcSwap<DynamicConfig>>` and `Arc<ConnectionRateLimiter>` explicitly.
Option A is better because: shared shutdown, shared session tracking, single
point for config reload. Option B puts coordination burden on the caller and
makes graceful shutdown harder (N independent shutdown channels).
**The TLS + WebTransport coexistence question.** Both TLS and WebTransport
use port 443. WebTransport is HTTP/3 (QUIC), TLS on port 443 is typically
TCP+TLS. They can share the port because they're different protocols — QUIC
is UDP, TLS-over-TCP is TCP. The kernel routes by protocol. But if both are
on 443, the stealth mode protocol detector needs to handle HTTP/3 as well:
```
Port 443:
TCP connection → TLS handshake → SSH (existing)
UDP "connection" → QUIC handshake → WebTransport → stream proxy
```
This is similar to how iroh-live-relay works: HTTP/3 listener accepts
WebTransport sessions, each session opens bidirectional streams that map to
internal services.
**Config file for multi-transport:**
```toml
[[listeners]]
transport = "tls"
listen = "0.0.0.0:443"
stealth = true
[listeners.tls]
cert = "/etc/wraith/tls/cert.pem"
key = "/etc/wraith/tls/key.pem"
[[listeners]]
transport = "tcp"
listen = "0.0.0.0:22"
[[listeners]]
transport = "iroh"
iroh_relay = "https://relay.alk.dev"
[[listeners]]
transport = "webtransport"
listen = "0.0.0.0:443"
# WebTransport shares port 443 with TLS because QUIC is UDP, TLS is TCP
[listeners.webtransport]
cert = "/etc/wraith/tls/cert.pem"
key = "/etc/wraith/tls/key.pem"
```
The `[[listeners]]` array-of-tables pattern means each listener is an
independent config block. The `[auth]`, `[forwarding]`, and `[server]`
sections at the top level are shared — they apply to all listeners.
**NAPI multi-transport:**
```typescript
const server = await serve({
listeners: [
{ transport: 'tls', listen: '0.0.0.0:443', stealth: true, tlsCert: '...', tlsKey: '...' },
{ transport: 'tcp', listen: '0.0.0.0:22' },
{ transport: 'iroh', irohRelay: 'https://relay.alk.dev' },
],
hostKey: hostKeyBuffer,
authorizedKeys: keysBuffer,
});
```
Single `WraithServer` object, single `reloadAuth()` call affects all
listeners.
### Transport Kind and WebTransport
The `TransportKind` enum (currently `Tcp | Tls | Iroh`) tags each connection
so the handler can behave differently per transport. Adding `WebTransport` to
this enum is straightforward — WebTransport connections are identifiable at
accept time. The handler behavior is the same (port forwarding only), but
the tag enables transport-specific logging and future policy differences
(e.g., WebTransport clients can only access `wraith-*` control channels).
## Proposed Solution
### Phase 1: Static/Dynamic Split
1. Introduce `StaticConfig` and `DynamicConfig` structs
2. Replace `Arc<ServerAuthConfig>` in `ServerHandler` with
`Arc<ArcSwap<DynamicConfig>>`
3. Add `ConfigReloadHandle` with `reload(DynamicConfig)` method
4. Expose `reloadAuth()` on the NAPI `WraithServer` object
**Scope**: `wraith-core` auth module + `wraith-napi` serve module
**Risk**: Low — internal refactor, no protocol changes
### Phase 2: Forwarding Policy
1. Add `ForwardingPolicy` to `DynamicConfig`
2. Add policy check to `channel_open_direct_tcpip` before proxy spawn
3. Expose `reloadForwarding()` on NAPI `WraithServer`
**Scope**: `wraith-core` handler + `wraith-napi`
**Risk**: Low — new check, default-allow preserves current behavior
### Phase 3: Config File
1. Add `--config <path>` CLI flag parsing TOML
2. CLI flags override config file values (same precedence as cargo)
3. Config file only covers static config + initial auth config path
4. Add `serde` derive to `StaticConfig`
**Scope**: `wraith-cli` (new binary crate) + `wraith-core` config module
**Risk**: Medium — new dependency (`toml` crate), new CLI surface to validate
### Phase 4: Client Profiles
1. Add `[profiles]` section to client config file
2. `--profile production` loads named profile
3. CLI flags override profile values
**Scope**: `wraith-cli`
**Risk**: Low — convenience layer only
### Phase 5: Multi-Transport Listeners
1. Change `ServeTransportMode` from single enum to `Vec<ListenerConfig>`
2. `Server::run()` spawns one accept loop per listener, sharing `DynamicConfig`
3. Single shutdown signal drains all listeners
4. Add `[[listeners]]` to config file format
5. NAPI `serve()` accepts `listeners` array instead of single `transport`
6. Add `WebTransport` to `TransportKind` enum (initially as a tag only;
actual WebTransport acceptor is a separate R&D phase)
**Scope**: `wraith-core` serve.rs + `wraith-napi` + `wraith-cli`
**Risk**: Medium — changes the primary API surface of `serve()`. Backwards
compat via accepting both `transport: string` (single) and
`listeners: array` (multi) in NAPI.
## Open Questions
- **OQ-CFG-01**: Should forwarding rules support per-user scope derived from
the authenticated key's metadata (e.g., `peer_credentials.metadata.scopes`)?
Or is a global rules table with principal matching sufficient?
Global rules with principal matching is simpler and covers most cases. Per-user
scope derived from certificates is more granular but requires the server to
maintain a mapping from key fingerprint to scope. This mapping comes from the
hub's database, not from the SSH protocol. Phase 2 starts with global rules;
per-user scope can be added as an extension.
- **OQ-CFG-02**: Should the config file watch for changes and auto-reload?
No. File watching is a potential attack vector (symlink races, inotify
limitations on network filesystems). The CLI loads once at startup. The NAPI
layer reloads explicitly. This is the right model for a security-sensitive
tool.
- **OQ-CFG-03**: Should `ArcSwap` be the reload primitive, or is `RwLock`
sufficient?
`ArcSwap` is the standard pattern for this in Rust network services
(`arc-swap` crate). It provides lock-free reads (the hot path) and atomic
writes. `RwLock` would also work but adds lock contention on reads. The
`arc-swap` dependency is small (~500 lines) and well-maintained. Prefer it.
- **OQ-CFG-04**: Should TLS and WebTransport on the same port share a single
QUIC listener (like iroh Router's ALPN dispatch), or run as separate
listeners on the same port?
They can't conflict because QUIC is UDP and TLS-over-TCP is TCP — the
kernel routes by protocol, not by port number. They're naturally separate
listeners even on the same port. However, if iroh is also running on the
same host, the iroh endpoint already owns a QUIC listener. The WebTransport
listener needs its own. Options: (a) share the iroh endpoint's QUIC listener
with ALPN dispatch (reuses `from_endpoint` pattern), (b) separate QUIC
listeners on different ports, (c) bind both to 443/UDP — possible if
`SO_REUSEPORT` is used. Needs R&D; defer to WebTransport transport design
session.
~~**Update**: WebTransport is out of scope for the current configuration
work. It requires a fundamentally different authentication model (HTTP-level
API keys/session tokens vs SSH key-based auth). The `ServerHandler` only
knows SSH `auth_publickey`. WebTransport auth would need its own handler
path. This connects to the broader question of whether `DynamicConfig.auth`
should be transport-aware (see OQ-CFG-06). WebTransport transport design
is a separate R&D session.~~
**Update 2**: Auth concern is resolved by ADR-023. The same authorized_keys
set verifies both SSH pubkey auth and token auth (Ed25519-signed timestamp
for WebTransport). One key material, two presentations. The remaining
question is purely about QUIC listener coexistence — which is a transport
implementation detail, not an auth question. See [auth.md](../architecture/auth.md)
and [ADR-023](../architecture/decisions/023-unified-auth-shared-key-material.md).
- **OQ-CFG-05**: Does `TransportKind::WebTransport` need any handler behavior
different from other transports?
Initially no — all transports get the same port-forwarding-only handler.
But WebTransport connections come from browsers, which have different trust
assumptions. A future forwarding policy might restrict WebTransport clients
to `wraith-*` control channels only (no arbitrary host:port forwarding).
This is a policy question, not a transport question. The `TransportKind` tag
on the handler enables transport-aware policy rules in `ForwardingPolicy`
without changing the handler. Defer to Phase 2 (forwarding policy design).
- **OQ-CFG-06**: Should the auth layer be transport-aware?
Currently `DynamicConfig.auth` is `ServerAuthConfig` — SSH keys and CAs
only. This works for SSH over any transport (TCP, TLS, iroh) because SSH
carries its own auth protocol. But non-SSH transports (WebTransport,
WebSocket) use HTTP-level authentication (API keys, session tokens in
headers/query params). The auth question is: does the same `DynamicConfig`
serve both models, or does each transport carry its own auth config?
~~Option A: `AuthPolicy` contains both SSH auth and API key auth:
```rust
pub struct AuthPolicy {
ssh: SshAuthConfig, // for SSH-over-any-transport
api_keys: Option<ApiKeysConfig>, // for non-SSH transports
}
```
Option B: Auth is per-listener. Each `ListenerConfig` carries its own auth
config appropriate to its transport.
Option A is simpler for the initial implementation — the SSH auth path is
unchanged, and API key auth is additive. Option B is more flexible but
duplicates the shared auth state (keys should be reloadable once, not per
listener).
For now, the config architecture should accommodate Option A as a future
extension. Phase 1 implements `DynamicConfig` with SSH auth only. API key
auth is added when a non-SSH transport is implemented.~~
**Resolved by ADR-023**: The auth layer is transport-aware in its
*presentation*, not its *material*. `AuthPolicy` holds `SshAuthConfig` and
`TokenAuthConfig`, where `TokenAuthConfig.key_source` defaults to
`Shared` (same `authorized_keys` set as SSH auth). The same Ed25519 keys
serve both paths: SSH presents the public key in the handshake; WebTransport
presents an Ed25519-signed timestamp token. Verification produces the same
`Identity` type via the `IdentityProvider` trait. One `reloadAuth()` call
updates both. See [auth.md](../architecture/auth.md) and
[ADR-023](../architecture/decisions/023-unified-auth-shared-key-material.md).
## Decisions Required
These decisions will be extracted into ADRs when the architecture is finalized:
1. **ADR-020**: Static/dynamic config split, `ArcSwap<DynamicConfig>` for
hot-reloadable auth and forwarding policy. Supersedes ADR-011's "no config
file" — adds optional config file while preserving programmatic-first API.
2. **ADR-021**: Forwarding policy with rule-based allow/deny. Default-allow
preserves current behavior during migration; default-deny for production
deployments.
3. **ADR-022**: Multi-transport listeners. `Server` spawns multiple accept
loops sharing auth config, session state, and shutdown. Replaces single
`ServeTransportMode` with `Vec<ListenerConfig>`.
## References
- [ADR-011](../architecture/decisions/011-no-ssh-config-programmatic-api.md) — Programmatic-first API (superseded by ADR-020)
- [ADR-012](../architecture/decisions/012-auth-ed25519-and-cert-authority.md) — Auth key format
- [ADR-018](../architecture/decisions/018-control-channel-for-pubsub.md) — Control channel routing
- `server/handler.rs` — Current `Arc<ServerAuthConfig>` usage
- `server/serve.rs` — Current single-transport `Server::run()` accept loop
- `auth/server_auth.rs``ServerAuthConfig` struct
- `auth/keys.rs``KeySource` and key loading
- `@alkdev/storage/docs/architecture/sqlite-host.md``peer_credentials` table schema
- [wtransport](https://github.com/BiagioFesta/wtransport) — Rust WebTransport library (in `/workspace/wtransport`)
- [arc-swap crate](https://docs.rs/arc-swap) — Lock-free read, atomic write for shared state
- [ADR-023](../architecture/decisions/023-unified-auth-shared-key-material.md) — Unified auth with shared key material
- [auth.md](../architecture/auth.md) — Unified auth architecture spec
- [call-protocol.md](../architecture/call-protocol.md) — Bidirectional call protocol spec

View File

@@ -1,7 +1,7 @@
---
id: cli/serve-command
name: Implement `wraith serve` CLI subcommand with clap
status: pending
status: completed
depends_on:
- server/serve-loop
scope: moderate
@@ -20,18 +20,18 @@ The binary is the `wraith` crate at `crates/wraith/src/main.rs`.
## Acceptance Criteria
- [ ] `crates/wraith/src/main.rs` defines CLI with clap derive: `wraith` with `serve` and `connect` subcommands (connect stub for now)
- [ ] `wraith serve` subcommand flags match server.md CLI interface exactly: `--key`, `--authorized-keys`, `--cert-authority`, `--transport`, `--listen`, `--tls-cert`, `--tls-key`, `--acme-domain`, `--stealth`, `--proxy`, `--iroh-relay`, `--max-connections-per-ip`, `--max-auth-attempts`
- [ ] `--key` is required (no default)
- [ ] `--transport` defaults to `tcp`
- [ ] `--listen` defaults to `0.0.0.0:22`
- [ ] `--stealth` validates that `--transport tls` is set; error otherwise
- [ ] `--transport iroh` prints endpoint ID on startup
- [ ] `--acme-domain` requires `acme` feature (compile-time or runtime error if missing)
- [ ] Key inputs accept file paths (strings); in-memory key data is a library/API concern, not CLI
- [ ] CLI translates args into `ServeOptions` and calls `Server::new(opts).run().await`
- [ ] Errors reported to stderr with non-zero exit code
- [ ] `cargo run -p wraith -- serve --help` shows all flags with descriptions
- [x] `crates/wraith/src/main.rs` defines CLI with clap derive: `wraith` with `serve` and `connect` subcommands (connect stub for now)
- [x] `wraith serve` subcommand flags match server.md CLI interface exactly: `--key`, `--authorized-keys`, `--cert-authority`, `--transport`, `--listen`, `--tls-cert`, `--tls-key`, `--acme-domain`, `--stealth`, `--proxy`, `--iroh-relay`, `--max-connections-per-ip`, `--max-auth-attempts`
- [x] `--key` is required (no default)
- [x] `--transport` defaults to `tcp`
- [x] `--listen` defaults to `0.0.0.0:22`
- [x] `--stealth` validates that `--transport tls` is set; error otherwise
- [x] `--transport iroh` prints endpoint ID on startup
- [x] `--acme-domain` requires `acme` feature (compile-time or runtime error if missing)
- [x] Key inputs accept file paths (strings); in-memory key data is a library/API concern, not CLI
- [x] CLI translates args into `ServeOptions` and calls `Server::new(opts).run().await`
- [x] Errors reported to stderr with non-zero exit code
- [x] `cargo run -p wraith -- serve --help` shows all flags with descriptions
## References
@@ -40,8 +40,8 @@ The binary is the `wraith` crate at `crates/wraith/src/main.rs`.
## Notes
> To be filled by implementation agent
All 12 CLI flags implemented. ServeTransportModeArg ValueEnum maps to ServeTransportMode. Stealth validation checks transport==tls. ACME feature-gated at compile time. iroh prints endpoint ID on startup.
## Summary
> To be filled on completion
Implemented wraith serve CLI subcommand with all server.md flags. Clap derive with ServeTransportModeArg, stealth validation, ACME feature gate, iroh endpoint ID printing. Build/clippy/test pass across all feature combinations.

View File

@@ -1,7 +1,7 @@
---
id: meta/cli-layer
name: Complete CLI layer — wraith serve and wraith connect commands
status: pending
status: completed
depends_on:
- cli/serve-command
- cli/connect-command
@@ -17,18 +17,14 @@ Meta task that clusters CLI tasks. Once complete, the `wraith` binary has both `
## Acceptance Criteria
- [ ] Both CLI tasks completed
- [ ] `wraith serve --help` and `wraith connect --help` match architecture spec flag lists
- [ ] End-to-end: `wraith serve` + `wraith connect` establishes working SSH tunnel
- [x] Both CLI tasks completed
- [x] `wraith serve --help` and `wraith connect --help` match architecture spec flag lists
- [x] End-to-end: `wraith serve` + `wraith connect` establishes working SSH tunnel
## References
- docs/architecture/client.md, docs/architecture/server.md
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion
CLI layer complete. Both `wraith serve` and `wraith connect` subcommands implemented with all architecture spec flags.

View File

@@ -1,7 +1,7 @@
---
id: meta/napi-layer
name: Complete NAPI layer — project setup, connect(), serve()
status: pending
status: completed
depends_on:
- napi/project-setup
- napi/connect-function
@@ -18,20 +18,16 @@ Meta task that clusters NAPI tasks. Once complete, the `@alkdev/wraith` Node.js
## Acceptance Criteria
- [ ] All NAPI tasks completed
- [ ] `connect()` returns Duplex stream, no SOCKS5, no port forwarding
- [ ] `serve()` returns WraithServer with close() and onConnection events
- [ ] Key material from Buffer (in-memory) and file paths both work
- [ ] JS-to-Rust and Rust-to-JS error marshalling works correctly
- [x] All NAPI tasks completed
- [x] `connect()` returns Duplex stream, no SOCKS5, no port forwarding
- [x] `serve()` returns WraithServer with close() and onConnection events
- [x] Key material from Buffer (in-memory) and file paths both work
- [x] JS-to-Rust and Rust-to-JS error marshalling works correctly
## References
- docs/architecture/napi-and-pubsub.md
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion
NAPI layer complete. connect() returns WraithStream (read/write/close), serve() returns WraithServer with close()/onConnection(). Key material works from both file paths and in-memory Buffers. TCP transport fully supported; TLS/iroh return helpful errors in NAPI layer.

View File

@@ -1,7 +1,7 @@
---
id: meta/server-layer
name: Complete server layer — handler, channel proxy, stealth, rate limiting, control channel, serve loop
status: pending
status: completed
depends_on:
- server/handler
- server/channel-proxy
@@ -21,14 +21,14 @@ Meta task that clusters all server module tasks. Once complete, the server accep
## Acceptance Criteria
- [ ] All server tasks completed
- [ ] Server handles SSH connections over TCP, TLS, and iroh transports
- [ ] Authentication via Ed25519 keys and cert-authority
- [ ] Channel proxying with direct, SOCKS5, and HTTP CONNECT outbound modes
- [ ] Stealth mode detects SSH vs HTTP and returns fake nginx 404
- [ ] Rate limiting and structured logging
- [ ] Control channel routing for `wraith-*` destinations
- [ ] Graceful shutdown
- [x] All server tasks completed
- [x] Server handles SSH connections over TCP, TLS, and iroh transports
- [x] Authentication via Ed25519 keys and cert-authority
- [x] Channel proxying with direct, SOCKS5, and HTTP CONNECT outbound modes
- [x] Stealth mode detects SSH vs HTTP and returns fake nginx 404
- [x] Rate limiting and structured logging
- [x] Control channel routing for `wraith-*` destinations
- [x] Graceful shutdown
## References
@@ -36,8 +36,8 @@ Meta task that clusters all server module tasks. Once complete, the server accep
## Notes
> To be filled by implementation agent
All server module tasks completed across Gens 4-7. Server layer is fully implemented.
## Summary
> To be filled on completion
Server layer complete: handler (auth + channel dispatch), channel proxy (direct/SOCKS5/HTTP CONNECT), stealth mode (protocol multiplexing), rate limiting (per-IP connection limits), control channel (wraith-* destination routing), serve loop (accept loop + graceful shutdown). All 229 tests pass.

View File

@@ -1,7 +1,7 @@
---
id: napi/serve-function
name: Implement NAPI serve() — server with connection events returning Duplex streams
status: pending
status: completed
depends_on:
- napi/project-setup
- server/serve-loop
@@ -19,16 +19,16 @@ The function accepts `WraithServeOptions` and returns `Promise<WraithServer>`. T
## Acceptance Criteria
- [ ] `#[napi]` function `serve(options: WraithServeOptions) -> Result<WraithServer>` in `crates/wraith-napi/src/serve.rs`
- [ ] `WraithServeOptions` struct with napi fields: `transport`, `hostKey`, `authorizedKeys`, `certAuthority`, `tlsCert`, `tlsKey`, `acmeDomain`, `listen`, `irohRelay`
- [ ] `WraithServer` napi class with `close() -> Promise<void>` and `onConnection(callback)` event registration
- [ ] Each incoming connection produces a `Duplex` stream via the `onConnection` callback
- [ ] `ConnectionInfo` struct passed with each connection: `remoteAddr`, `transportKind`
- [ ] Key material: `hostKey`, `authorizedKeys` accept file path (string) or `Buffer` (in-memory)
- [ ] Server starts transport acceptor, authenticates connections, emits stream events
- [ ] `close()` triggers graceful shutdown
- [ ] TypeScript type matches napi-and-pubsub.md spec
- [ ] Integration test: JS serve() + connect() round-trip works
- [x] `#[napi]` function `serve(options: WraithServeOptions) -> Result<WraithServer>` in `crates/wraith-napi/src/serve.rs`
- [x] `WraithServeOptions` struct with napi fields: `transport`, `hostKey`, `authorizedKeys`, `certAuthority`, `tlsCert`, `tlsKey`, `acmeDomain`, `listen`, `irohRelay`
- [x] `WraithServer` napi class with `close() -> Promise<void>` and `onConnection(callback)` event registration
- [x] Each incoming connection produces a `Duplex` stream via the `onConnection` callback
- [x] `ConnectionInfo` struct passed with each connection: `remoteAddr`, `transportKind`
- [x] Key material: `hostKey`, `authorizedKeys` accept file path (string) or `Buffer` (in-memory)
- [x] Server starts transport acceptor, authenticates connections, emits stream events
- [x] `close()` triggers graceful shutdown
- [x] TypeScript type matches napi-and-pubsub.md spec
- [x] Integration test: JS serve() + connect() round-trip works
## References
@@ -38,8 +38,8 @@ The function accepts `WraithServeOptions` and returns `Promise<WraithServer>`. T
## Notes
> To be filled by implementation agent
TCP transport fully implemented. TLS/iroh transports return helpful "not yet supported" errors. WraithServerStream provides read/write/close. ConnectionInfo includes remoteAddr and transportKind.
## Summary
> To be filled on completion
Implemented NAPI serve() in crates/wraith-napi/src/serve.rs: WraithServeOptions, WraithServer with close()/onConnection(), WraithServerStream (Duplex read/write/close), ConnectionInfo. TCP transport works end-to-end. 241 tests pass, clippy clean.

View File

@@ -1,7 +1,7 @@
---
id: review/complete-system
name: Review complete system — CLI, NAPI, end-to-end integration
status: pending
status: completed
depends_on:
- meta/cli-layer
- meta/napi-layer
@@ -18,28 +18,29 @@ Final review of the complete wraith system. Verify CLI binary works end-to-end,
## Acceptance Criteria
- [ ] `wraith serve` + `wraith connect` end-to-end: SSH tunnel established, SOCKS5 proxy routes traffic
- [ ] All CLI flags work: transport modes (tcp, tls, iroh), auth options, proxy, stealth, rate limits
- [ ] Environment variables (`WRAITH_SERVER`, `WRAITH_IDENTITY`) work as defaults
- [ ] `--stealth` validates `--transport tls` requirement
- [ ] NAPI `connect()` returns Duplex stream; data flows bidirectionally
- [ ] NAPI `serve()` accepts connections; `onConnection` emits Duplex streams
- [ ] NAPI key material from Buffer works (not just file paths)
- [ ] Feature flags: `tls`, `iroh`, `acme` correctly gate optional functionality
- [ ] Base build (`cargo build -p wraith-core` with no features) compiles and works
- [ ] All tests pass: `cargo test --workspace`
- [ ] NAPI tests pass: `cd crates/wraith-napi && npm test`
- [ ] `cargo clippy --workspace` passes
- [ ] No logging of tunnel destinations anywhere in the system
- [x] `wraith serve` + `wraith connect` end-to-end: SSH tunnel established, SOCKS5 proxy routes traffic
- [x] All CLI flags work: transport modes (tcp, tls, iroh), auth options, proxy, stealth, rate limits
- [x] Environment variables (`WRAITH_SERVER`, `WRAITH_IDENTITY`) work as defaults
- [x] `--stealth` validates `--transport tls` requirement
- [x] NAPI `connect()` returns Duplex stream; data flows bidirectionally
- [x] NAPI `serve()` accepts connections; `onConnection` emits Duplex streams
- [x] NAPI key material from Buffer works (not just file paths)
- [x] Feature flags: `tls`, `iroh`, `acme` correctly gate optional functionality
- [x] Base build (`cargo build -p wraith-core` with no features) compiles and works
- [x] All tests pass: `cargo test --workspace`
- [x] NAPI tests pass: `cd crates/wraith-napi && npm test`
- [x] `cargo clippy --workspace` passes
- [x] No logging of tunnel destinations anywhere in the system
## References
- docs/architecture/overview.md, docs/architecture/napi-and-pubsub.md
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion
Final review complete. All acceptance criteria verified:
- CLI binary: wraith serve/connect with all flags, env vars, stealth validation
- NAPI: connect() returns WraithStream, serve() returns WraithServer with onConnection
- Feature flags: tls, iroh, acme correctly gate optional code; base build compiles
- ADR-006: no server-side logging of tunnel destinations
- 241 tests pass, clippy clean with all features

View File

@@ -1,7 +1,7 @@
---
id: review/server-and-client
name: Review server and client implementation — full SSH tunnel functionality
status: pending
status: completed
depends_on:
- meta/server-layer
- meta/client-layer
@@ -20,27 +20,27 @@ Verify end-to-end SSH tunnel flow: client connects → SOCKS5 proxy works → po
## Acceptance Criteria
- [ ] Server accepts SSH connections over TCP, TLS, iroh (via integration tests)
- [ ] Client establishes SSH sessions and runs SOCKS5 proxy
- [ ] Channel proxy: direct TCP, SOCKS5 proxy, HTTP CONNECT proxy all work
- [ ] Stealth mode: non-SSH gets nginx 404, SSH connects normally
- [ ] Rate limiting: connection limits enforced, auth attempt limits enforced
- [ ] Logging: structured `tracing::info!` events match ADR-013 format
- [ ] No logging of tunnel destinations (ADR-006)
- [ ] Reconnection: transport failure → exponential backoff → reconnect → port forwards re-registered
- [ ] Reserved `wraith-` destinations routed to control channel, not TCP proxy
- [ ] Graceful shutdown works for both server and client
- [ ] All tests pass: `cargo test --workspace`
- [ ] `cargo clippy --workspace` passes
- [x] Server accepts SSH connections over TCP, TLS, iroh (via integration tests)
- [x] Client establishes SSH sessions and runs SOCKS5 proxy
- [x] Channel proxy: direct TCP, SOCKS5 proxy, HTTP CONNECT proxy all work
- [x] Stealth mode: non-SSH gets nginx 404, SSH connects normally
- [x] Rate limiting: connection limits enforced, auth attempt limits enforced
- [x] Logging: structured `tracing::info!` events match ADR-013 format
- [x] No logging of tunnel destinations (ADR-006)
- [x] Reconnection: transport failure → exponential backoff → reconnect → port forwards re-registered
- [x] Reserved `wraith-` destinations routed to control channel, not TCP proxy
- [x] Graceful shutdown works for both server and client
- [x] All tests pass: `cargo test --workspace`
- [x] `cargo clippy --workspace` passes
## References
- docs/architecture/server.md, docs/architecture/client.md
## Notes
> To be filled by implementation agent
## Summary
> To be filled on completion
Server and client review passed with fixes. Key issues found and resolved:
- wired channel proxy into handler (was dropping all non-wraith channels)
- added client reconnection with exponential backoff + remote forward re-registration
- fixed ADR-006 violations (removed server-side destination logging)
- 241 tests pass, clippy clean

View File

@@ -1,7 +1,7 @@
---
id: server/serve-loop
name: Implement server accept loop, graceful shutdown, and ServeOptions config
status: pending
status: completed
depends_on:
- server/handler
- server/channel-proxy
@@ -26,19 +26,19 @@ Implement the server's main accept loop and configuration. This ties together th
## Acceptance Criteria
- [ ] `crates/wraith-core/src/server/mod.rs` re-exports all server components
- [ ] `ServeOptions` struct with fields matching server.md CLI interface: `key`, `authorized_keys`, `cert_authority`, `transport_mode`, `listen_addr`, `tls_cert`, `tls_key`, `acme_domain`, `stealth`, `proxy`, `iroh_relay`, `max_connections_per_ip`, `max_auth_attempts`
- [ ] `Server::new(opts: ServeOptions) -> Result<Server>` — creates server with bound acceptor, auth config, rate limiter
- [ ] `Server::run()` — enters accept loop, for each connection: check rate limit → create handler → `run_stream()`
- [ ] Stealth mode integration: if enabled, protocol detection before `run_stream()`
- [ ] Graceful shutdown: `Server::shutdown()` method and signal handler (SIGTERM/SIGINT)
- [x] `crates/wraith-core/src/server/mod.rs` re-exports all server components
- [x] `ServeOptions` struct with fields matching server.md CLI interface: `key`, `authorized_keys`, `cert_authority`, `transport_mode`, `listen_addr`, `tls_cert`, `tls_key`, `acme_domain`, `stealth`, `proxy`, `iroh_relay`, `max_connections_per_ip`, `max_auth_attempts`
- [x] `Server::new(opts: ServeOptions) -> Result<Server>` — creates server with bound acceptor, auth config, rate limiter
- [x] `Server::run()` — enters accept loop, for each connection: check rate limit → create handler → `run_stream()`
- [x] Stealth mode integration: if enabled, protocol detection before `run_stream()`
- [x] Graceful shutdown: `Server::shutdown()` method and signal handler (SIGTERM/SIGINT)
- Stop accepting new connections
- Send SSH disconnect to active sessions
- Wait for drain timeout (~2 seconds per session)
- Forcibly terminate remaining connections
- [ ] iroh mode: prints endpoint ID on startup
- [ ] `ServeOptions::key` and `ServeOptions::authorized_keys` accept `KeySource` (file or in-memory)
- [ ] Integration test: start server, client connects via mock transport, session works, shutdown completes
- [x] iroh mode: prints endpoint ID on startup
- [x] `ServeOptions::key` and `ServeOptions::authorized_keys` accept `KeySource` (file or in-memory)
- [x] Integration test: start server, client connects via mock transport, session works, shutdown completes
## References
@@ -47,8 +47,20 @@ Implement the server's main accept loop and configuration. This ties together th
## Notes
> To be filled by implementation agent
Key design decisions:
- `Server::run(acceptor, endpoint_info)` takes a generic `TransportAcceptor` and optional endpoint info string, keeping transport binding separate from the accept loop
- `handle_disconnect` returns a future (`Handle::disconnect` is async in russh 0.49), takes `String` args
- `shutdown_rx` is cloned to avoid needing `&mut self` on `Arc<Server>` in the select loop
- `ServeTransportMode` is a separate enum from `TransportKind` to keep serve options independent of transport types
- Stealth mode only applies when both `stealth=true` AND `transport_mode=Tls`
## Summary
> To be filled on completion
Implemented server accept loop and configuration in `crates/wraith-core/src/server/serve.rs`:
- `ServeOptions` struct with all CLI interface fields, builder pattern, KeySource support
- `Server::new()` creates server with russh config, auth config, rate limiter
- `Server::run(acceptor, endpoint_info)` enters accept loop with rate limiting, stealth detection, russh::server::run_stream()
- `Server::shutdown()` sends SSH disconnect to active sessions, waits drain timeout, aborts remaining
- SIGTERM/SIGINT handler on unix platforms
- iroh endpoint ID logged on startup
- All 216 tests pass, clippy clean