Compare commits
30 Commits
feat/serve
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fcbd65ba2 | |||
| af7f4d0006 | |||
| 41062d810e | |||
| 5ffcf9232b | |||
| d85c882635 | |||
| a7595f1718 | |||
| 37ff929a42 | |||
| 150b1f3ae5 | |||
| 053ace6fcc | |||
| f63589a5ca | |||
| 9b06f26a3c | |||
| e49aef05d3 | |||
| f057e868ce | |||
| 0fdb6cd782 | |||
| 62d57dd477 | |||
| c7b8c5c5e0 | |||
| 6297c07383 | |||
| 32a8c9a725 | |||
| 373b053820 | |||
| 94feb5fdac | |||
| f13a1c985f | |||
| 49fe2b699f | |||
| 365b11d19e | |||
| 7dcf7502b7 | |||
| 585913d3c8 | |||
| 243243a82f | |||
| 2ab5eeda53 | |||
| 128affd264 | |||
| 5a2b535605 | |||
| 24b70f5651 |
26
Cargo.lock
generated
26
Cargo.lock
generated
@@ -2395,6 +2395,7 @@ version = "3.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2"
|
checksum = "f1d395473824516f38dd1071a1a37bc57daa7be65b293ebba4ead5f7abb017a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"ctor",
|
"ctor",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -2402,6 +2403,7 @@ dependencies = [
|
|||||||
"napi-sys",
|
"napi-sys",
|
||||||
"nohash-hasher",
|
"nohash-hasher",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3861,6 +3863,15 @@ dependencies = [
|
|||||||
"x509-parser 0.16.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.1"
|
version = "1.14.1"
|
||||||
@@ -5583,7 +5594,13 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
"iroh",
|
||||||
|
"rustls",
|
||||||
|
"rustls-acme",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"url",
|
||||||
"wraith-core",
|
"wraith-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5593,6 +5610,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"futures",
|
||||||
"ipnetwork",
|
"ipnetwork",
|
||||||
"iroh",
|
"iroh",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
@@ -5618,8 +5636,16 @@ dependencies = [
|
|||||||
name = "wraith-napi"
|
name = "wraith-napi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"iroh",
|
||||||
"napi",
|
"napi",
|
||||||
"napi-derive",
|
"napi-derive",
|
||||||
|
"russh",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
"wraith-core",
|
"wraith-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,10 @@ members = [
|
|||||||
"crates/wraith",
|
"crates/wraith",
|
||||||
"crates/wraith-napi",
|
"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
192
LICENSE-APACHE
Normal 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
21
LICENSE-MIT
Normal 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
43
README.md
Normal 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.
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wraith-core"
|
name = "wraith-core"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
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]
|
[lib]
|
||||||
name = "wraith_core"
|
name = "wraith_core"
|
||||||
|
|||||||
@@ -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 std::path::PathBuf;
|
||||||
|
|
||||||
use russh::keys::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64};
|
use russh::keys::{PrivateKey, PublicKey, decode_secret_key, parse_public_key_base64};
|
||||||
|
|
||||||
use crate::error::ConfigError;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum KeySource {
|
pub enum KeySource {
|
||||||
File(PathBuf),
|
File(PathBuf),
|
||||||
Memory(Vec<u8>),
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CertAuthorityEntry {
|
pub struct CertAuthorityEntry {
|
||||||
pub public_key: PublicKey,
|
pub public_key: PublicKey,
|
||||||
|
|||||||
@@ -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 client_auth;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub mod server_auth;
|
pub mod server_auth;
|
||||||
|
|||||||
@@ -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::collections::HashSet;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::str::FromStr;
|
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 super::keys::{CertAuthorityEntry, KeySource, load_cert_authority_entries, load_public_keys};
|
||||||
use crate::error::AuthError;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ServerAuthConfig {
|
pub struct ServerAuthConfig {
|
||||||
pub authorized_keys: HashSet<PublicKey>,
|
pub authorized_keys: HashSet<PublicKey>,
|
||||||
|
|||||||
@@ -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::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|||||||
854
crates/wraith-core/src/client/connect.rs
Normal file
854
crates/wraith-core/src/client/connect.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -125,7 +132,7 @@ impl LocalForwarder {
|
|||||||
handle: Arc<Mutex<client::Handle<H>>>,
|
handle: Arc<Mutex<client::Handle<H>>>,
|
||||||
) -> Result<(), ForwardError> {
|
) -> Result<(), ForwardError> {
|
||||||
let listen_addr = self.spec.listen_addr()?;
|
let listen_addr = self.spec.listen_addr()?;
|
||||||
let listener = TcpListener::bind(listen_addr)
|
let listener: TcpListener = TcpListener::bind(listen_addr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ForwardError::BindFailed { source: e })?;
|
.map_err(|e| ForwardError::BindFailed { source: e })?;
|
||||||
self.listener = Some(listener);
|
self.listener = Some(listener);
|
||||||
|
|||||||
@@ -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 channel_manager;
|
||||||
|
pub mod connect;
|
||||||
pub mod forward;
|
pub mod forward;
|
||||||
|
|
||||||
pub use channel_manager::{ChannelManager, ForwardRequest};
|
pub use channel_manager::{ChannelManager, ForwardRequest};
|
||||||
|
pub use connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
|
||||||
pub use forward::{LocalForwarder, PortForwardSpec, PortForwardSpecKind, RemoteForwarder};
|
pub use forward::{LocalForwarder, PortForwardSpec, PortForwardSpecKind, RemoteForwarder};
|
||||||
@@ -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;
|
use std::io;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -62,7 +71,7 @@ pub enum ConfigError {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ForwardError {
|
pub enum ForwardError {
|
||||||
#[error("invalid forward specification: {spec}")]
|
#[error("invalid port forward spec: {spec}")]
|
||||||
InvalidSpec { spec: String },
|
InvalidSpec { spec: String },
|
||||||
#[error("bind failed")]
|
#[error("bind failed")]
|
||||||
BindFailed {
|
BindFailed {
|
||||||
@@ -74,6 +83,11 @@ pub enum ForwardError {
|
|||||||
#[source]
|
#[source]
|
||||||
source: Box<dyn std::error::Error + Send + Sync>,
|
source: Box<dyn std::error::Error + Send + Sync>,
|
||||||
},
|
},
|
||||||
|
#[error("connect to local target failed")]
|
||||||
|
LocalConnectFailed {
|
||||||
|
#[source]
|
||||||
|
source: io::Error,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -166,4 +180,36 @@ mod tests {
|
|||||||
let plain = AuthError::KeyRejected;
|
let plain = AuthError::KeyRejected;
|
||||||
assert!(plain.source().is_none());
|
assert!(plain.source().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forward_error_display() {
|
||||||
|
assert_eq!(
|
||||||
|
ForwardError::InvalidSpec { spec: "bad".to_string() }.to_string(),
|
||||||
|
"invalid port forward spec: bad"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ForwardError::BindFailed {
|
||||||
|
source: io::Error::new(io::ErrorKind::AddrInUse, "in use")
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
"bind failed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ForwardError::LocalConnectFailed {
|
||||||
|
source: io::Error::new(io::ErrorKind::ConnectionRefused, "refused")
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
"connect to local target failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forward_error_source_chaining() {
|
||||||
|
let io_err = io::Error::new(io::ErrorKind::AddrInUse, "in use");
|
||||||
|
let forward_err = ForwardError::BindFailed { source: io_err };
|
||||||
|
assert!(forward_err.source().is_some());
|
||||||
|
|
||||||
|
let plain = ForwardError::InvalidSpec { spec: "bad".to_string() };
|
||||||
|
assert!(plain.source().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 transport;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
@@ -10,4 +62,6 @@ pub mod testutil;
|
|||||||
|
|
||||||
pub use error::{AuthError, ChannelError, ConfigError, ForwardError, TransportError};
|
pub use error::{AuthError, ChannelError, ConfigError, ForwardError, TransportError};
|
||||||
pub use transport::{Transport, TransportAcceptor, TransportInfo, TransportKind};
|
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};
|
||||||
563
crates/wraith-core/src/server/channel_proxy.rs
Normal file
563
crates/wraith-core/src/server/channel_proxy.rs
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
//! Outbound connection proxy for SSH channel targets.
|
||||||
|
//!
|
||||||
|
//! Connects to the requested `host:port` either directly, via SOCKS5 proxy, or
|
||||||
|
//! via HTTP CONNECT proxy, then proxies bytes bidirectionally between the SSH
|
||||||
|
//! channel and the outbound TCP stream.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use super::handler::{ProxyConfig, ProxyMode};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ChannelProxyError {
|
||||||
|
#[error("connection refused")]
|
||||||
|
ConnectionRefused,
|
||||||
|
#[error("target unreachable")]
|
||||||
|
TargetUnreachable,
|
||||||
|
#[error("socks5 proxy handshake failed")]
|
||||||
|
Socks5HandshakeFailed,
|
||||||
|
#[error("socks5 proxy rejected connection")]
|
||||||
|
Socks5ProxyRejected,
|
||||||
|
#[error("http connect proxy handshake failed")]
|
||||||
|
HttpConnectHandshakeFailed,
|
||||||
|
#[error("http connect proxy rejected: {0}")]
|
||||||
|
HttpConnectProxyRejected(String),
|
||||||
|
#[error("io error")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect_outbound(
|
||||||
|
target: SocketAddr,
|
||||||
|
proxy: &ProxyConfig,
|
||||||
|
) -> Result<TcpStream, ChannelProxyError> {
|
||||||
|
match &proxy.mode {
|
||||||
|
ProxyMode::Direct => connect_direct(target).await,
|
||||||
|
ProxyMode::Socks5(addr) => connect_socks5(target, *addr).await,
|
||||||
|
ProxyMode::HttpConnect(addr) => connect_http_connect(target, *addr).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_direct(target: SocketAddr) -> Result<TcpStream, ChannelProxyError> {
|
||||||
|
TcpStream::connect(target)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_connection_error(e, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_socks5(target: SocketAddr, proxy_addr: SocketAddr) -> Result<TcpStream, ChannelProxyError> {
|
||||||
|
let mut stream = TcpStream::connect(proxy_addr)
|
||||||
|
.await
|
||||||
|
.map_err(ChannelProxyError::from)?;
|
||||||
|
|
||||||
|
stream.write_all(&[0x05, 0x01, 0x00]).await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
|
||||||
|
let mut resp = [0u8; 2];
|
||||||
|
stream.read_exact(&mut resp).await?;
|
||||||
|
if resp[0] != 0x05 || resp[1] != 0x00 {
|
||||||
|
return Err(ChannelProxyError::Socks5HandshakeFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ip_bytes = target.ip().to_string();
|
||||||
|
let mut connect_req = vec![0x05, 0x01, 0x00, 0x03];
|
||||||
|
connect_req.push(ip_bytes.len() as u8);
|
||||||
|
connect_req.extend_from_slice(ip_bytes.as_bytes());
|
||||||
|
connect_req.extend_from_slice(&target.port().to_be_bytes());
|
||||||
|
stream.write_all(&connect_req).await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
|
||||||
|
let mut reply_header = [0u8; 4];
|
||||||
|
stream.read_exact(&mut reply_header).await?;
|
||||||
|
if reply_header[0] != 0x05 {
|
||||||
|
return Err(ChannelProxyError::Socks5HandshakeFailed);
|
||||||
|
}
|
||||||
|
if reply_header[1] != 0x00 {
|
||||||
|
return Err(ChannelProxyError::Socks5ProxyRejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let atyp = reply_header[3];
|
||||||
|
match atyp {
|
||||||
|
0x01 => {
|
||||||
|
let mut _addr = [0u8; 4];
|
||||||
|
stream.read_exact(&mut _addr).await?;
|
||||||
|
}
|
||||||
|
0x04 => {
|
||||||
|
let mut _addr = [0u8; 16];
|
||||||
|
stream.read_exact(&mut _addr).await?;
|
||||||
|
}
|
||||||
|
0x03 => {
|
||||||
|
let len = stream.read_u8().await?;
|
||||||
|
let mut _domain = vec![0u8; len as usize];
|
||||||
|
stream.read_exact(&mut _domain).await?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(ChannelProxyError::Socks5HandshakeFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut _port = [0u8; 2];
|
||||||
|
stream.read_exact(&mut _port).await?;
|
||||||
|
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_http_connect(
|
||||||
|
target: SocketAddr,
|
||||||
|
proxy_addr: SocketAddr,
|
||||||
|
) -> Result<TcpStream, ChannelProxyError> {
|
||||||
|
let mut stream = TcpStream::connect(proxy_addr)
|
||||||
|
.await
|
||||||
|
.map_err(ChannelProxyError::from)?;
|
||||||
|
|
||||||
|
let connect_request = format!(
|
||||||
|
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n\r\n",
|
||||||
|
target.ip(),
|
||||||
|
target.port(),
|
||||||
|
target.ip(),
|
||||||
|
target.port()
|
||||||
|
);
|
||||||
|
stream.write_all(connect_request.as_bytes()).await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
|
||||||
|
let mut response = Vec::new();
|
||||||
|
let mut buf = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(ChannelProxyError::HttpConnectHandshakeFailed);
|
||||||
|
}
|
||||||
|
response.extend_from_slice(&buf[..n]);
|
||||||
|
if response.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_str = String::from_utf8_lossy(&response);
|
||||||
|
let status_line = response_str
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if status_line.contains("200") {
|
||||||
|
Ok(stream)
|
||||||
|
} else {
|
||||||
|
Err(ChannelProxyError::HttpConnectProxyRejected(
|
||||||
|
status_line.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_connection_error(e: std::io::Error, _target: SocketAddr) -> ChannelProxyError {
|
||||||
|
match e.kind() {
|
||||||
|
std::io::ErrorKind::ConnectionRefused => ChannelProxyError::ConnectionRefused,
|
||||||
|
std::io::ErrorKind::AddrNotAvailable
|
||||||
|
| std::io::ErrorKind::NetworkUnreachable
|
||||||
|
| std::io::ErrorKind::HostUnreachable => ChannelProxyError::TargetUnreachable,
|
||||||
|
_ => ChannelProxyError::Io(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn proxy_channel<S>(channel: S, target: SocketAddr, proxy: &ProxyConfig)
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||||
|
{
|
||||||
|
if let Ok(outbound) = connect_outbound(target, proxy).await {
|
||||||
|
let (mut read_chan, mut write_chan) = tokio::io::split(channel);
|
||||||
|
let (mut read_out, mut write_out) = outbound.into_split();
|
||||||
|
|
||||||
|
let client_to_target = tokio::spawn(async move {
|
||||||
|
let _ = tokio::io::copy(&mut read_chan, &mut write_out).await;
|
||||||
|
let _ = write_out.shutdown().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let target_to_client = tokio::spawn(async move {
|
||||||
|
let _ = tokio::io::copy(&mut read_out, &mut write_chan).await;
|
||||||
|
let _ = write_chan.shutdown().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = client_to_target.await;
|
||||||
|
let _ = target_to_client.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt, DuplexStream};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
fn direct_config() -> ProxyConfig {
|
||||||
|
ProxyConfig {
|
||||||
|
mode: ProxyMode::Direct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn socks5_config(addr: SocketAddr) -> ProxyConfig {
|
||||||
|
ProxyConfig {
|
||||||
|
mode: ProxyMode::Socks5(addr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_connect_config(addr: SocketAddr) -> ProxyConfig {
|
||||||
|
ProxyConfig {
|
||||||
|
mode: ProxyMode::HttpConnect(addr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn direct_connection_to_echo_server() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap();
|
||||||
|
sock.write_all(&buf[..n]).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = connect_outbound(addr, &direct_config()).await.unwrap();
|
||||||
|
let (mut read, mut write) = stream.into_split();
|
||||||
|
write.write_all(b"hello").await.unwrap();
|
||||||
|
let mut buf = [0u8; 5];
|
||||||
|
read.read_exact(&mut buf).await.unwrap();
|
||||||
|
assert_eq!(&buf, b"hello");
|
||||||
|
|
||||||
|
let _ = server.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn direct_connection_target_unreachable() {
|
||||||
|
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
|
||||||
|
let result = connect_outbound(target, &direct_config()).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn socks5_proxy_handshake() {
|
||||||
|
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let proxy_addr = proxy_listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let target_addr = target_listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let target_server = tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = target_listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap();
|
||||||
|
sock.write_all(&buf[..n]).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let proxy_server = tokio::spawn(async move {
|
||||||
|
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
let mut greeting = [0u8; 3];
|
||||||
|
proxy_sock.read_exact(&mut greeting).await.unwrap();
|
||||||
|
assert_eq!(greeting[0], 0x05);
|
||||||
|
proxy_sock.write_all(&[0x05, 0x00]).await.unwrap();
|
||||||
|
|
||||||
|
let mut req_header = [0u8; 4];
|
||||||
|
proxy_sock.read_exact(&mut req_header).await.unwrap();
|
||||||
|
assert_eq!(req_header[0], 0x05);
|
||||||
|
assert_eq!(req_header[1], 0x01);
|
||||||
|
|
||||||
|
let atyp = req_header[3];
|
||||||
|
assert_eq!(atyp, 0x03);
|
||||||
|
|
||||||
|
let domain_len = proxy_sock.read_u8().await.unwrap() as usize;
|
||||||
|
let mut domain = vec![0u8; domain_len];
|
||||||
|
proxy_sock.read_exact(&mut domain).await.unwrap();
|
||||||
|
let mut port_bytes = [0u8; 2];
|
||||||
|
proxy_sock.read_exact(&mut port_bytes).await.unwrap();
|
||||||
|
|
||||||
|
let target: SocketAddr = format!(
|
||||||
|
"{}:{}",
|
||||||
|
String::from_utf8_lossy(&domain),
|
||||||
|
u16::from_be_bytes(port_bytes)
|
||||||
|
)
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let reply = vec![
|
||||||
|
0x05, 0x00, 0x00, 0x01,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 0,
|
||||||
|
];
|
||||||
|
proxy_sock.write_all(&reply).await.unwrap();
|
||||||
|
|
||||||
|
let mut target_stream = TcpStream::connect(target).await.unwrap();
|
||||||
|
let _ = tokio::io::copy_bidirectional(&mut proxy_sock, &mut target_stream).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = socks5_config(proxy_addr);
|
||||||
|
let mut stream = connect_outbound(target_addr, &config).await.unwrap();
|
||||||
|
stream.write_all(b"hello socks").await.unwrap();
|
||||||
|
let mut buf = [0u8; 11];
|
||||||
|
stream.read_exact(&mut buf).await.unwrap();
|
||||||
|
assert_eq!(&buf, b"hello socks");
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
let _ = target_server.await;
|
||||||
|
let _ = proxy_server.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn socks5_proxy_rejected() {
|
||||||
|
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let proxy_addr = proxy_listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let proxy_server = tokio::spawn(async move {
|
||||||
|
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
let mut greeting = [0u8; 3];
|
||||||
|
proxy_sock.read_exact(&mut greeting).await.unwrap();
|
||||||
|
proxy_sock.write_all(&[0x05, 0x00]).await.unwrap();
|
||||||
|
|
||||||
|
let mut req_header = [0u8; 4];
|
||||||
|
proxy_sock.read_exact(&mut req_header).await.unwrap();
|
||||||
|
|
||||||
|
let domain_len = proxy_sock.read_u8().await.unwrap() as usize;
|
||||||
|
let mut domain = vec![0u8; domain_len];
|
||||||
|
proxy_sock.read_exact(&mut domain).await.unwrap();
|
||||||
|
let mut port_bytes = [0u8; 2];
|
||||||
|
proxy_sock.read_exact(&mut port_bytes).await.unwrap();
|
||||||
|
|
||||||
|
let reply = vec![
|
||||||
|
0x05, 0x05, 0x00, 0x01,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 0,
|
||||||
|
];
|
||||||
|
proxy_sock.write_all(&reply).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||||
|
let config = socks5_config(proxy_addr);
|
||||||
|
let result = connect_outbound(target, &config).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
result.unwrap_err(),
|
||||||
|
ChannelProxyError::Socks5ProxyRejected
|
||||||
|
));
|
||||||
|
|
||||||
|
let _ = proxy_server.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_connect_proxy_handshake() {
|
||||||
|
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let proxy_addr = proxy_listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let target_addr = target_listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let target_server = tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = target_listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap();
|
||||||
|
sock.write_all(&buf[..n]).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let proxy_server = tokio::spawn(async move {
|
||||||
|
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buf = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
let n = proxy_sock.read(&mut buf).await.unwrap();
|
||||||
|
request.extend_from_slice(&buf[..n]);
|
||||||
|
if request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = "HTTP/1.1 200 Connection Established\r\n\r\n";
|
||||||
|
proxy_sock.write_all(response.as_bytes()).await.unwrap();
|
||||||
|
|
||||||
|
let target_str = extract_connect_target(&String::from_utf8_lossy(&request));
|
||||||
|
let mut target_stream = TcpStream::connect(target_str).await.unwrap();
|
||||||
|
let _ = tokio::io::copy_bidirectional(&mut proxy_sock, &mut target_stream).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let config = http_connect_config(proxy_addr);
|
||||||
|
let mut stream = connect_outbound(target_addr, &config).await.unwrap();
|
||||||
|
stream.write_all(b"hello http").await.unwrap();
|
||||||
|
let mut buf = [0u8; 10];
|
||||||
|
stream.read_exact(&mut buf).await.unwrap();
|
||||||
|
assert_eq!(&buf, b"hello http");
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
let _ = target_server.await;
|
||||||
|
let _ = proxy_server.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_connect_target(request: &str) -> String {
|
||||||
|
let connect_line = request.lines().next().unwrap_or("");
|
||||||
|
let parts: Vec<&str> = connect_line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 2 {
|
||||||
|
parts[1].to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_connect_proxy_rejected() {
|
||||||
|
let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let proxy_addr = proxy_listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let proxy_server = tokio::spawn(async move {
|
||||||
|
let (mut proxy_sock, _) = proxy_listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut buf = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
let n = proxy_sock.read(&mut buf).await.unwrap();
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
request.extend_from_slice(&buf[..n]);
|
||||||
|
if request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = "HTTP/1.1 403 Forbidden\r\n\r\n";
|
||||||
|
proxy_sock.write_all(response.as_bytes()).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||||
|
let config = http_connect_config(proxy_addr);
|
||||||
|
let result = connect_outbound(target, &config).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result.unwrap_err() {
|
||||||
|
ChannelProxyError::HttpConnectProxyRejected(msg) => {
|
||||||
|
assert!(msg.contains("403"));
|
||||||
|
}
|
||||||
|
other => panic!("expected HttpConnectProxyRejected, got {:?}", other),
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = proxy_server.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn target_unreachable_returns_appropriate_error() {
|
||||||
|
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
|
||||||
|
let result = connect_outbound(target, &direct_config()).await;
|
||||||
|
match result.unwrap_err() {
|
||||||
|
ChannelProxyError::TargetUnreachable
|
||||||
|
| ChannelProxyError::ConnectionRefused
|
||||||
|
| ChannelProxyError::Io(_) => {}
|
||||||
|
other => panic!("unexpected error type: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn socks5_proxy_unreachable() {
|
||||||
|
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||||
|
let bad_proxy: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||||
|
let config = socks5_config(bad_proxy);
|
||||||
|
let result = connect_outbound(target, &config).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_connect_proxy_unreachable() {
|
||||||
|
let target: SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||||
|
let bad_proxy: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||||
|
let config = http_connect_config(bad_proxy);
|
||||||
|
let result = connect_outbound(target, &config).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockChannel {
|
||||||
|
read_half: tokio::io::ReadHalf<DuplexStream>,
|
||||||
|
write_half: tokio::io::WriteHalf<DuplexStream>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl tokio::io::AsyncRead for MockChannel {
|
||||||
|
fn poll_read(
|
||||||
|
self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
buf: &mut tokio::io::ReadBuf<'_>,
|
||||||
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
std::pin::Pin::new(&mut self.get_mut().read_half).poll_read(cx, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl tokio::io::AsyncWrite for MockChannel {
|
||||||
|
fn poll_write(
|
||||||
|
self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
buf: &[u8],
|
||||||
|
) -> std::task::Poll<std::io::Result<usize>> {
|
||||||
|
std::pin::Pin::new(&mut self.get_mut().write_half).poll_write(cx, buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_flush(
|
||||||
|
self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
std::pin::Pin::new(&mut self.get_mut().write_half).poll_flush(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_shutdown(
|
||||||
|
self: std::pin::Pin<&mut Self>,
|
||||||
|
cx: &mut std::task::Context<'_>,
|
||||||
|
) -> std::task::Poll<std::io::Result<()>> {
|
||||||
|
std::pin::Pin::new(&mut self.get_mut().write_half).poll_shutdown(cx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_mock_channel() -> (MockChannel, DuplexStream) {
|
||||||
|
let (client, server) = duplex(4096);
|
||||||
|
let (read_half, write_half) = tokio::io::split(client);
|
||||||
|
(
|
||||||
|
MockChannel {
|
||||||
|
read_half,
|
||||||
|
write_half,
|
||||||
|
},
|
||||||
|
server,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proxy_channel_bidirectional_data_flow() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let target_addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let echo_server = tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap();
|
||||||
|
sock.write_all(&buf[..n]).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let (channel, mut channel_peer) = make_mock_channel();
|
||||||
|
|
||||||
|
let target = target_addr;
|
||||||
|
let proxy = direct_config();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
proxy_channel(channel, target, &proxy).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
channel_peer.write_all(b"ping").await.unwrap();
|
||||||
|
channel_peer.flush().await.unwrap();
|
||||||
|
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
channel_peer.read_exact(&mut buf).await.unwrap();
|
||||||
|
assert_eq!(&buf, b"ping");
|
||||||
|
|
||||||
|
drop(channel_peer);
|
||||||
|
let _ = echo_server.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn proxy_channel_target_unreachable_closes_cleanly() {
|
||||||
|
let target: SocketAddr = "240.0.0.1:1".parse().unwrap();
|
||||||
|
let (channel, _channel_peer) = make_mock_channel();
|
||||||
|
|
||||||
|
let proxy = direct_config();
|
||||||
|
proxy_channel(channel, target, &proxy).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! 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 std::io;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
use std::net::SocketAddr;
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use russh::keys::ssh_key::HashAlg;
|
use russh::keys::ssh_key::HashAlg;
|
||||||
use russh::server::{Auth, Handler, Msg, Session};
|
use russh::server::{Auth, Handler, Msg, Session};
|
||||||
use russh::Channel;
|
use russh::Channel;
|
||||||
|
use russh::ChannelId;
|
||||||
|
|
||||||
use crate::auth::ServerAuthConfig;
|
use crate::auth::ServerAuthConfig;
|
||||||
use crate::server::control_channel::{
|
use crate::server::control_channel::{
|
||||||
ControlChannelHandler, ControlChannelRouter, WRAITH_PREFIX,
|
ControlChannelHandler, ControlChannelRouter, WRAITH_PREFIX,
|
||||||
};
|
};
|
||||||
|
use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ProxyMode {
|
pub enum ProxyMode {
|
||||||
@@ -23,11 +26,35 @@ pub struct ProxyConfig {
|
|||||||
pub mode: ProxyMode,
|
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 {
|
pub struct ServerHandler {
|
||||||
auth_config: Arc<ServerAuthConfig>,
|
auth_config: Arc<ServerAuthConfig>,
|
||||||
|
#[allow(dead_code)]
|
||||||
outbound_proxy: Option<ProxyConfig>,
|
outbound_proxy: Option<ProxyConfig>,
|
||||||
remote_addr: Option<SocketAddr>,
|
remote_addr: Option<SocketAddr>,
|
||||||
control_channel_router: ControlChannelRouter,
|
control_channel_router: ControlChannelRouter,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
transport: TransportKind,
|
||||||
|
connection_limiter: Arc<ConnectionRateLimiter>,
|
||||||
|
connection_allowed: bool,
|
||||||
|
auth_limiter: AuthAttemptLimiter,
|
||||||
|
connected_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerHandler {
|
impl ServerHandler {
|
||||||
@@ -35,15 +62,71 @@ impl ServerHandler {
|
|||||||
auth_config: Arc<ServerAuthConfig>,
|
auth_config: Arc<ServerAuthConfig>,
|
||||||
outbound_proxy: Option<ProxyConfig>,
|
outbound_proxy: Option<ProxyConfig>,
|
||||||
remote_addr: Option<SocketAddr>,
|
remote_addr: Option<SocketAddr>,
|
||||||
|
transport: TransportKind,
|
||||||
|
connection_limiter: Arc<ConnectionRateLimiter>,
|
||||||
|
max_auth_attempts: usize,
|
||||||
) -> Self {
|
) -> 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 {
|
Self {
|
||||||
auth_config,
|
auth_config,
|
||||||
outbound_proxy,
|
outbound_proxy,
|
||||||
remote_addr,
|
remote_addr,
|
||||||
control_channel_router: ControlChannelRouter::without_handler(),
|
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(
|
pub fn with_control_channel_handler(
|
||||||
mut self,
|
mut self,
|
||||||
handler: Box<dyn ControlChannelHandler>,
|
handler: Box<dyn ControlChannelHandler>,
|
||||||
@@ -66,6 +149,23 @@ impl Handler for ServerHandler {
|
|||||||
user: &str,
|
user: &str,
|
||||||
public_key: &russh::keys::ssh_key::PublicKey,
|
public_key: &russh::keys::ssh_key::PublicKey,
|
||||||
) -> Result<Auth, Self::Error> {
|
) -> 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 fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256));
|
||||||
let remote_addr_display = self
|
let remote_addr_display = self
|
||||||
.remote_addr
|
.remote_addr
|
||||||
@@ -78,6 +178,7 @@ impl Handler for ServerHandler {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
remote_addr = %remote_addr_display,
|
remote_addr = %remote_addr_display,
|
||||||
|
user = user,
|
||||||
key_fingerprint = %fingerprint,
|
key_fingerprint = %fingerprint,
|
||||||
result = "accept",
|
result = "accept",
|
||||||
"auth attempt"
|
"auth attempt"
|
||||||
@@ -85,8 +186,10 @@ impl Handler for ServerHandler {
|
|||||||
Ok(Auth::Accept)
|
Ok(Auth::Accept)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
self.auth_limiter.on_failure();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
remote_addr = %remote_addr_display,
|
remote_addr = %remote_addr_display,
|
||||||
|
user = user,
|
||||||
key_fingerprint = %fingerprint,
|
key_fingerprint = %fingerprint,
|
||||||
result = "reject",
|
result = "reject",
|
||||||
"auth attempt"
|
"auth attempt"
|
||||||
@@ -108,17 +211,7 @@ impl Handler for ServerHandler {
|
|||||||
_session: &mut Session,
|
_session: &mut Session,
|
||||||
) -> Result<bool, Self::Error> {
|
) -> Result<bool, Self::Error> {
|
||||||
if host_to_connect.starts_with(WRAITH_PREFIX) {
|
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() {
|
if !self.control_channel_router.has_handler() {
|
||||||
tracing::warn!(
|
|
||||||
host = host_to_connect,
|
|
||||||
"no control channel handler configured, rejecting channel open"
|
|
||||||
);
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,30 +219,40 @@ impl Handler for ServerHandler {
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let proxy_info = self
|
let target_host = host_to_connect.to_string();
|
||||||
.outbound_proxy
|
let target_port = port_to_connect;
|
||||||
.as_ref()
|
let proxy_config = self.outbound_proxy.clone().unwrap_or(ProxyConfig {
|
||||||
.map(|p| format!("{:?}", p.mode))
|
mode: ProxyMode::Direct,
|
||||||
.unwrap_or_else(|| "direct".to_string());
|
});
|
||||||
|
|
||||||
tracing::info!(
|
tokio::spawn(async move {
|
||||||
host = host_to_connect,
|
let target = match format!("{target_host}:{target_port}").parse::<std::net::SocketAddr>() {
|
||||||
port = port_to_connect,
|
Ok(addr) => addr,
|
||||||
originator_address = originator_address,
|
Err(_) => match tokio::net::lookup_host((&target_host[..], target_port as u16)).await {
|
||||||
originator_port = originator_port,
|
Ok(mut addrs) => match addrs.next() {
|
||||||
proxy = %proxy_info,
|
Some(addr) => addr,
|
||||||
"spawning tcp proxy task"
|
None => return,
|
||||||
);
|
},
|
||||||
|
Err(_) => return,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
crate::server::channel_proxy::proxy_channel(channel.into_stream(), target, &proxy_config).await;
|
||||||
|
});
|
||||||
|
|
||||||
let _ = channel;
|
let _ = (originator_address, originator_port);
|
||||||
Ok(false)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn channel_open_session(
|
async fn channel_open_session(
|
||||||
&mut self,
|
&mut self,
|
||||||
_channel: Channel<Msg>,
|
_channel: Channel<Msg>,
|
||||||
_session: &mut Session,
|
session: &mut Session,
|
||||||
) -> Result<bool, Self::Error> {
|
) -> Result<bool, Self::Error> {
|
||||||
|
tracing::warn!(
|
||||||
|
remote_addr = ?self.remote_addr,
|
||||||
|
"rejected session channel (shell/exec not supported)"
|
||||||
|
);
|
||||||
|
let _ = session;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,22 +261,208 @@ impl Handler for ServerHandler {
|
|||||||
_channel: Channel<Msg>,
|
_channel: Channel<Msg>,
|
||||||
_originator_address: &str,
|
_originator_address: &str,
|
||||||
_originator_port: u32,
|
_originator_port: u32,
|
||||||
_session: &mut Session,
|
session: &mut Session,
|
||||||
) -> Result<bool, Self::Error> {
|
) -> Result<bool, Self::Error> {
|
||||||
|
tracing::warn!(
|
||||||
|
remote_addr = ?self.remote_addr,
|
||||||
|
"rejected x11 channel"
|
||||||
|
);
|
||||||
|
let _ = session;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn channel_open_forwarded_tcpip(
|
async fn channel_open_forwarded_tcpip(
|
||||||
&mut self,
|
&mut self,
|
||||||
_channel: Channel<Msg>,
|
_channel: Channel<Msg>,
|
||||||
_host_to_connect: &str,
|
host_to_connect: &str,
|
||||||
_port_to_connect: u32,
|
port_to_connect: u32,
|
||||||
_originator_address: &str,
|
_originator_address: &str,
|
||||||
_originator_port: u32,
|
_originator_port: u32,
|
||||||
_session: &mut Session,
|
session: &mut Session,
|
||||||
) -> Result<bool, Self::Error> {
|
) -> 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)
|
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)]
|
#[cfg(test)]
|
||||||
@@ -213,10 +502,22 @@ mod tests {
|
|||||||
Arc::new(ServerAuthConfig::from_keys_and_ca(None, None).unwrap())
|
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]
|
#[tokio::test]
|
||||||
async fn auth_delegation_accepts_known_key() {
|
async fn auth_delegation_accepts_known_key() {
|
||||||
let auth_config = make_auth_config(ED25519_PUBLIC_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 ssh_key = load_key().public_key().clone();
|
||||||
let result = handler.auth_publickey("testuser", &ssh_key).await.unwrap();
|
let result = handler.auth_publickey("testuser", &ssh_key).await.unwrap();
|
||||||
@@ -226,7 +527,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn auth_delegation_rejects_unknown_key() {
|
async fn auth_delegation_rejects_unknown_key() {
|
||||||
let auth_config = make_auth_config(ED25519_PUBLIC_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_key_text = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE other@host";
|
||||||
let other_ssh_key = russh::keys::parse_public_key_base64(
|
let other_ssh_key = russh::keys::parse_public_key_base64(
|
||||||
@@ -249,7 +550,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn auth_delegation_empty_config_rejects_all() {
|
async fn auth_delegation_empty_config_rejects_all() {
|
||||||
let auth_config = make_empty_auth_config();
|
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 ssh_key = load_key().public_key().clone();
|
||||||
let result = handler
|
let result = handler
|
||||||
@@ -268,7 +569,7 @@ mod tests {
|
|||||||
async fn auth_logging_includes_remote_addr() {
|
async fn auth_logging_includes_remote_addr() {
|
||||||
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
|
let auth_config = make_auth_config(ED25519_PUBLIC_KEY);
|
||||||
let remote_addr: SocketAddr = "203.0.113.50:12345".parse().unwrap();
|
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 ssh_key = load_key().public_key().clone();
|
||||||
let _ = handler.auth_publickey("root", &ssh_key).await.unwrap();
|
let _ = handler.auth_publickey("root", &ssh_key).await.unwrap();
|
||||||
@@ -288,7 +589,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn server_handler_without_control_handler_rejects_wraith_destinations() {
|
fn server_handler_without_control_handler_rejects_wraith_destinations() {
|
||||||
let auth_config = make_empty_auth_config();
|
let auth_config = make_empty_auth_config();
|
||||||
let handler = ServerHandler::new(auth_config, None, None);
|
let handler = make_handler(auth_config, None, None);
|
||||||
assert!(!handler.control_channel_router().has_handler());
|
assert!(!handler.control_channel_router().has_handler());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +621,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let remote: Option<SocketAddr> = Some("10.0.0.1:22".parse().unwrap());
|
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.outbound_proxy.is_some());
|
||||||
assert!(handler.remote_addr.is_some());
|
assert!(handler.remote_addr.is_some());
|
||||||
}
|
}
|
||||||
@@ -328,9 +629,108 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn one_handler_per_connection() {
|
fn one_handler_per_connection() {
|
||||||
let auth_config = make_empty_auth_config();
|
let auth_config = make_empty_auth_config();
|
||||||
let handler1 = ServerHandler::new(auth_config.clone(), None, Some("10.0.0.1:22".parse().unwrap()));
|
let handler1 = make_handler(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 handler2 = make_handler(auth_config.clone(), None, Some("10.0.0.2:22".parse().unwrap()));
|
||||||
|
|
||||||
assert!(handler1.remote_addr != handler2.remote_addr);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
|
//! 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 mod channel_proxy;
|
||||||
pub mod control_channel;
|
pub mod control_channel;
|
||||||
pub mod handler;
|
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::{
|
pub use control_channel::{
|
||||||
ControlChannelHandler, ControlChannelRouter, DuplexStream, WRAITH_CONTROL_DESTINATION,
|
ControlChannelHandler, ControlChannelRouter, DuplexStream, WRAITH_CONTROL_DESTINATION,
|
||||||
WRAITH_PREFIX, is_reserved_destination,
|
WRAITH_PREFIX, is_reserved_destination,
|
||||||
};
|
};
|
||||||
pub use handler::{ProxyConfig, ProxyMode, ServerHandler};
|
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};
|
||||||
200
crates/wraith-core/src/server/rate_limit.rs
Normal file
200
crates/wraith-core/src/server/rate_limit.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
765
crates/wraith-core/src/server/serve.rs
Normal file
765
crates/wraith-core/src/server/serve.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
226
crates/wraith-core/src/server/stealth.rs
Normal file
226
crates/wraith-core/src/server/stealth.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
mod protocol;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio::io;
|
|||||||
|
|
||||||
use super::{Transport, TransportAcceptor, TransportInfo, TransportKind};
|
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/";
|
const DEFAULT_RELAY_URL: &str = "https://relay.iroh.network/";
|
||||||
|
|
||||||
/// A client-side iroh QUIC P2P transport that connects to a remote iroh endpoint.
|
/// 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
|
/// QUIC stream with `conn.open_bi()`, and joins the halves with
|
||||||
/// `tokio::io::join(recv, send)` to produce a duplex stream for russh.
|
/// `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.
|
/// 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 {
|
pub struct IrohTransport {
|
||||||
node_id: NodeId,
|
node_id: NodeId,
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
|
owned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IrohTransport {
|
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(
|
pub async fn new(
|
||||||
node_id: NodeId,
|
node_id: NodeId,
|
||||||
relay_url: Option<RelayUrl>,
|
relay_url: Option<RelayUrl>,
|
||||||
@@ -40,7 +49,18 @@ impl IrohTransport {
|
|||||||
builder = builder.proxy_url(proxy.clone());
|
builder = builder.proxy_url(proxy.clone());
|
||||||
}
|
}
|
||||||
let endpoint = builder.bind().await?;
|
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 {
|
pub fn endpoint_id(&self) -> String {
|
||||||
@@ -50,6 +70,10 @@ impl IrohTransport {
|
|||||||
pub fn endpoint(&self) -> &Endpoint {
|
pub fn endpoint(&self) -> &Endpoint {
|
||||||
&self.endpoint
|
&self.endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn owned(&self) -> bool {
|
||||||
|
self.owned
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -73,11 +97,24 @@ impl Transport for IrohTransport {
|
|||||||
/// (ADR-010). Accepts incoming connections, accepts bidirectional QUIC streams,
|
/// (ADR-010). Accepts incoming connections, accepts bidirectional QUIC streams,
|
||||||
/// and joins the halves with `tokio::io::join(recv, send)`. Exposes
|
/// 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.
|
/// `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 {
|
pub struct IrohAcceptor {
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
|
owned: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IrohAcceptor {
|
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(
|
pub async fn bind(
|
||||||
relay_url: Option<RelayUrl>,
|
relay_url: Option<RelayUrl>,
|
||||||
proxy_url: Option<url::Url>,
|
proxy_url: Option<url::Url>,
|
||||||
@@ -93,7 +130,23 @@ impl IrohAcceptor {
|
|||||||
builder = builder.proxy_url(proxy.clone());
|
builder = builder.proxy_url(proxy.clone());
|
||||||
}
|
}
|
||||||
let endpoint = builder.bind().await?;
|
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 {
|
pub fn endpoint_id(&self) -> String {
|
||||||
@@ -103,6 +156,10 @@ impl IrohAcceptor {
|
|||||||
pub fn endpoint(&self) -> &Endpoint {
|
pub fn endpoint(&self) -> &Endpoint {
|
||||||
&self.endpoint
|
&self.endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn owned(&self) -> bool {
|
||||||
|
self.owned
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -140,6 +197,7 @@ mod tests {
|
|||||||
assert!(!endpoint_id.is_empty());
|
assert!(!endpoint_id.is_empty());
|
||||||
let parsed = NodeId::from_z32(&endpoint_id);
|
let parsed = NodeId::from_z32(&endpoint_id);
|
||||||
assert!(parsed.is_ok());
|
assert!(parsed.is_ok());
|
||||||
|
assert!(acceptor.owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -147,6 +205,16 @@ mod tests {
|
|||||||
let relay: RelayUrl = "https://relay.iroh.network/".parse().unwrap();
|
let relay: RelayUrl = "https://relay.iroh.network/".parse().unwrap();
|
||||||
let acceptor = IrohAcceptor::bind(Some(relay), None).await.unwrap();
|
let acceptor = IrohAcceptor::bind(Some(relay), None).await.unwrap();
|
||||||
assert!(!acceptor.endpoint_id().is_empty());
|
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]
|
#[test]
|
||||||
@@ -166,6 +234,20 @@ mod tests {
|
|||||||
let transport = IrohTransport::new(node_id, None, None).await.unwrap();
|
let transport = IrohTransport::new(node_id, None, None).await.unwrap();
|
||||||
assert!(transport.describe().starts_with("iroh://"));
|
assert!(transport.describe().starts_with("iroh://"));
|
||||||
assert!(!transport.endpoint_id().is_empty());
|
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]
|
#[tokio::test]
|
||||||
@@ -202,4 +284,38 @@ mod tests {
|
|||||||
transport.connect().await.unwrap();
|
transport.connect().await.unwrap();
|
||||||
let _server_stream = accept_handle.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
mod tcp;
|
||||||
#[cfg(feature = "iroh")]
|
#[cfg(feature = "iroh")]
|
||||||
mod iroh_transport;
|
mod iroh_transport;
|
||||||
|
|
||||||
pub use tcp::{TcpAcceptor, TcpTransport};
|
pub use tcp::{TcpAcceptor, TcpTransport};
|
||||||
#[cfg(feature = "iroh")]
|
#[cfg(feature = "iroh")]
|
||||||
pub use iroh_transport::{IrohAcceptor, IrohTransport};
|
pub use iroh_transport::{IrohAcceptor, IrohTransport, ALPN as IROH_ALPN};
|
||||||
|
|
||||||
#[cfg(feature = "tls")]
|
#[cfg(feature = "tls")]
|
||||||
mod tls;
|
mod tls;
|
||||||
@@ -24,19 +39,33 @@ use anyhow::Result;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
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]
|
#[async_trait]
|
||||||
pub trait Transport: Send + Sync + 'static {
|
pub trait Transport: Send + Sync + 'static {
|
||||||
|
/// The duplex stream type produced by this transport.
|
||||||
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
|
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
|
||||||
|
|
||||||
|
/// Connect to the remote endpoint and return a duplex stream.
|
||||||
async fn connect(&self) -> Result<Self::Stream>;
|
async fn connect(&self) -> Result<Self::Stream>;
|
||||||
|
|
||||||
|
/// Return a human-readable description of this transport for logging.
|
||||||
fn describe(&self) -> String;
|
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]
|
#[async_trait]
|
||||||
pub trait TransportAcceptor: Send + Sync + 'static {
|
pub trait TransportAcceptor: Send + Sync + 'static {
|
||||||
|
/// The duplex stream type produced by this acceptor.
|
||||||
type Stream: AsyncRead + AsyncWrite + Unpin + Send + 'static;
|
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)>;
|
async fn accept(&self) -> Result<(Self::Stream, TransportInfo)>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wraith-napi"
|
name = "wraith-napi"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
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]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wraith-core = { path = "../wraith-core" }
|
wraith-core = { path = "../wraith-core", features = ["tls", "iroh"] }
|
||||||
napi = "3"
|
napi = { version = "3", features = ["async", "error_anyhow"] }
|
||||||
napi-derive = "3"
|
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"
|
||||||
304
crates/wraith-napi/src/connect.rs
Normal file
304
crates/wraith-napi/src/connect.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
#[allow(unused_imports)]
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate napi_derive;
|
extern crate napi_derive;
|
||||||
|
|
||||||
|
mod connect;
|
||||||
|
mod serve;
|
||||||
|
|||||||
1036
crates/wraith-napi/src/serve.rs
Normal file
1036
crates/wraith-napi/src/serve.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,29 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wraith"
|
name = "wraith"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "CLI binary for Wraith: self-hostable SSH tunnel tool with pluggable transports"
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "wraith"
|
name = "wraith"
|
||||||
path = "src/main.rs"
|
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]
|
[dependencies]
|
||||||
wraith-core = { path = "../wraith-core" }
|
wraith-core = { path = "../wraith-core" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
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 }
|
||||||
@@ -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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
status: reviewed
|
status: draft
|
||||||
last_updated: 2026-06-02
|
last_updated: 2026-06-04
|
||||||
---
|
---
|
||||||
|
|
||||||
# Wraith Architecture
|
# Wraith Architecture
|
||||||
|
|
||||||
## Current State
|
## 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
|
## 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 |
|
| [overview.md](overview.md) | reviewed | Package purpose, exports, dependencies |
|
||||||
| [transport.md](transport.md) | reviewed | Transport abstraction: TCP, TLS, iroh |
|
| [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 |
|
| [client.md](client.md) | reviewed | Client connection, SOCKS5, port forwarding |
|
||||||
| [server.md](server.md) | reviewed | Server acceptance, channel handling, proxy |
|
| [server.md](server.md) | reviewed | Server acceptance, channel handling, proxy |
|
||||||
| [tun-shim.md](tun-shim.md) | deprecated | TUN interface wrapper — **deferred**, use tun2proxy |
|
| [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 |
|
| [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 Table
|
||||||
|
|
||||||
| ADR | Title | Status |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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
|
## 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
|
## Lifecycle Definitions
|
||||||
|
|
||||||
|
|||||||
261
docs/architecture/auth.md
Normal file
261
docs/architecture/auth.md
Normal 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
|
||||||
402
docs/architecture/call-protocol.md
Normal file
402
docs/architecture/call-protocol.md
Normal 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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
73
docs/architecture/decisions/025-handler-spec-separation.md
Normal file
73
docs/architecture/decisions/025-handler-spec-separation.md
Normal 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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: reviewed
|
status: draft
|
||||||
last_updated: 2026-06-02
|
last_updated: 2026-06-04
|
||||||
---
|
---
|
||||||
|
|
||||||
# Open Questions
|
# Open Questions
|
||||||
@@ -90,4 +90,87 @@ last_updated: 2026-06-02
|
|||||||
- **Status**: ~~resolved~~
|
- **Status**: ~~resolved~~
|
||||||
- **Priority**: ~~low~~ —
|
- **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.
|
- **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
|
||||||
588
docs/research/configuration.md
Normal file
588
docs/research/configuration.md
Normal 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: cli/serve-command
|
id: cli/serve-command
|
||||||
name: Implement `wraith serve` CLI subcommand with clap
|
name: Implement `wraith serve` CLI subcommand with clap
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- server/serve-loop
|
- server/serve-loop
|
||||||
scope: moderate
|
scope: moderate
|
||||||
@@ -20,18 +20,18 @@ The binary is the `wraith` crate at `crates/wraith/src/main.rs`.
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `crates/wraith/src/main.rs` defines CLI with clap derive: `wraith` with `serve` and `connect` subcommands (connect stub for now)
|
- [x] `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`
|
- [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`
|
||||||
- [ ] `--key` is required (no default)
|
- [x] `--key` is required (no default)
|
||||||
- [ ] `--transport` defaults to `tcp`
|
- [x] `--transport` defaults to `tcp`
|
||||||
- [ ] `--listen` defaults to `0.0.0.0:22`
|
- [x] `--listen` defaults to `0.0.0.0:22`
|
||||||
- [ ] `--stealth` validates that `--transport tls` is set; error otherwise
|
- [x] `--stealth` validates that `--transport tls` is set; error otherwise
|
||||||
- [ ] `--transport iroh` prints endpoint ID on startup
|
- [x] `--transport iroh` prints endpoint ID on startup
|
||||||
- [ ] `--acme-domain` requires `acme` feature (compile-time or runtime error if missing)
|
- [x] `--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
|
- [x] 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`
|
- [x] CLI translates args into `ServeOptions` and calls `Server::new(opts).run().await`
|
||||||
- [ ] Errors reported to stderr with non-zero exit code
|
- [x] Errors reported to stderr with non-zero exit code
|
||||||
- [ ] `cargo run -p wraith -- serve --help` shows all flags with descriptions
|
- [x] `cargo run -p wraith -- serve --help` shows all flags with descriptions
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ The binary is the `wraith` crate at `crates/wraith/src/main.rs`.
|
|||||||
|
|
||||||
## Notes
|
## 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
|
## 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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: meta/cli-layer
|
id: meta/cli-layer
|
||||||
name: Complete CLI layer — wraith serve and wraith connect commands
|
name: Complete CLI layer — wraith serve and wraith connect commands
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- cli/serve-command
|
- cli/serve-command
|
||||||
- cli/connect-command
|
- cli/connect-command
|
||||||
@@ -17,18 +17,14 @@ Meta task that clusters CLI tasks. Once complete, the `wraith` binary has both `
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Both CLI tasks completed
|
- [x] Both CLI tasks completed
|
||||||
- [ ] `wraith serve --help` and `wraith connect --help` match architecture spec flag lists
|
- [x] `wraith serve --help` and `wraith connect --help` match architecture spec flag lists
|
||||||
- [ ] End-to-end: `wraith serve` + `wraith connect` establishes working SSH tunnel
|
- [x] End-to-end: `wraith serve` + `wraith connect` establishes working SSH tunnel
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- docs/architecture/client.md, docs/architecture/server.md
|
- docs/architecture/client.md, docs/architecture/server.md
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
> To be filled by implementation agent
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
CLI layer complete. Both `wraith serve` and `wraith connect` subcommands implemented with all architecture spec flags.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: meta/napi-layer
|
id: meta/napi-layer
|
||||||
name: Complete NAPI layer — project setup, connect(), serve()
|
name: Complete NAPI layer — project setup, connect(), serve()
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- napi/project-setup
|
- napi/project-setup
|
||||||
- napi/connect-function
|
- napi/connect-function
|
||||||
@@ -18,20 +18,16 @@ Meta task that clusters NAPI tasks. Once complete, the `@alkdev/wraith` Node.js
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] All NAPI tasks completed
|
- [x] All NAPI tasks completed
|
||||||
- [ ] `connect()` returns Duplex stream, no SOCKS5, no port forwarding
|
- [x] `connect()` returns Duplex stream, no SOCKS5, no port forwarding
|
||||||
- [ ] `serve()` returns WraithServer with close() and onConnection events
|
- [x] `serve()` returns WraithServer with close() and onConnection events
|
||||||
- [ ] Key material from Buffer (in-memory) and file paths both work
|
- [x] Key material from Buffer (in-memory) and file paths both work
|
||||||
- [ ] JS-to-Rust and Rust-to-JS error marshalling works correctly
|
- [x] JS-to-Rust and Rust-to-JS error marshalling works correctly
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- docs/architecture/napi-and-pubsub.md
|
- docs/architecture/napi-and-pubsub.md
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
> To be filled by implementation agent
|
|
||||||
|
|
||||||
## Summary
|
## 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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: meta/server-layer
|
id: meta/server-layer
|
||||||
name: Complete server layer — handler, channel proxy, stealth, rate limiting, control channel, serve loop
|
name: Complete server layer — handler, channel proxy, stealth, rate limiting, control channel, serve loop
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- server/handler
|
- server/handler
|
||||||
- server/channel-proxy
|
- server/channel-proxy
|
||||||
@@ -21,14 +21,14 @@ Meta task that clusters all server module tasks. Once complete, the server accep
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] All server tasks completed
|
- [x] All server tasks completed
|
||||||
- [ ] Server handles SSH connections over TCP, TLS, and iroh transports
|
- [x] Server handles SSH connections over TCP, TLS, and iroh transports
|
||||||
- [ ] Authentication via Ed25519 keys and cert-authority
|
- [x] Authentication via Ed25519 keys and cert-authority
|
||||||
- [ ] Channel proxying with direct, SOCKS5, and HTTP CONNECT outbound modes
|
- [x] Channel proxying with direct, SOCKS5, and HTTP CONNECT outbound modes
|
||||||
- [ ] Stealth mode detects SSH vs HTTP and returns fake nginx 404
|
- [x] Stealth mode detects SSH vs HTTP and returns fake nginx 404
|
||||||
- [ ] Rate limiting and structured logging
|
- [x] Rate limiting and structured logging
|
||||||
- [ ] Control channel routing for `wraith-*` destinations
|
- [x] Control channel routing for `wraith-*` destinations
|
||||||
- [ ] Graceful shutdown
|
- [x] Graceful shutdown
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ Meta task that clusters all server module tasks. Once complete, the server accep
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> To be filled by implementation agent
|
All server module tasks completed across Gens 4-7. Server layer is fully implemented.
|
||||||
|
|
||||||
## Summary
|
## 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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: napi/serve-function
|
id: napi/serve-function
|
||||||
name: Implement NAPI serve() — server with connection events returning Duplex streams
|
name: Implement NAPI serve() — server with connection events returning Duplex streams
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- napi/project-setup
|
- napi/project-setup
|
||||||
- server/serve-loop
|
- server/serve-loop
|
||||||
@@ -19,16 +19,16 @@ The function accepts `WraithServeOptions` and returns `Promise<WraithServer>`. T
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `#[napi]` function `serve(options: WraithServeOptions) -> Result<WraithServer>` in `crates/wraith-napi/src/serve.rs`
|
- [x] `#[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`
|
- [x] `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
|
- [x] `WraithServer` napi class with `close() -> Promise<void>` and `onConnection(callback)` event registration
|
||||||
- [ ] Each incoming connection produces a `Duplex` stream via the `onConnection` callback
|
- [x] Each incoming connection produces a `Duplex` stream via the `onConnection` callback
|
||||||
- [ ] `ConnectionInfo` struct passed with each connection: `remoteAddr`, `transportKind`
|
- [x] `ConnectionInfo` struct passed with each connection: `remoteAddr`, `transportKind`
|
||||||
- [ ] Key material: `hostKey`, `authorizedKeys` accept file path (string) or `Buffer` (in-memory)
|
- [x] Key material: `hostKey`, `authorizedKeys` accept file path (string) or `Buffer` (in-memory)
|
||||||
- [ ] Server starts transport acceptor, authenticates connections, emits stream events
|
- [x] Server starts transport acceptor, authenticates connections, emits stream events
|
||||||
- [ ] `close()` triggers graceful shutdown
|
- [x] `close()` triggers graceful shutdown
|
||||||
- [ ] TypeScript type matches napi-and-pubsub.md spec
|
- [x] TypeScript type matches napi-and-pubsub.md spec
|
||||||
- [ ] Integration test: JS serve() + connect() round-trip works
|
- [x] Integration test: JS serve() + connect() round-trip works
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@ The function accepts `WraithServeOptions` and returns `Promise<WraithServer>`. T
|
|||||||
|
|
||||||
## Notes
|
## 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
|
## 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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: review/complete-system
|
id: review/complete-system
|
||||||
name: Review complete system — CLI, NAPI, end-to-end integration
|
name: Review complete system — CLI, NAPI, end-to-end integration
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- meta/cli-layer
|
- meta/cli-layer
|
||||||
- meta/napi-layer
|
- meta/napi-layer
|
||||||
@@ -18,28 +18,29 @@ Final review of the complete wraith system. Verify CLI binary works end-to-end,
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `wraith serve` + `wraith connect` end-to-end: SSH tunnel established, SOCKS5 proxy routes traffic
|
- [x] `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
|
- [x] All CLI flags work: transport modes (tcp, tls, iroh), auth options, proxy, stealth, rate limits
|
||||||
- [ ] Environment variables (`WRAITH_SERVER`, `WRAITH_IDENTITY`) work as defaults
|
- [x] Environment variables (`WRAITH_SERVER`, `WRAITH_IDENTITY`) work as defaults
|
||||||
- [ ] `--stealth` validates `--transport tls` requirement
|
- [x] `--stealth` validates `--transport tls` requirement
|
||||||
- [ ] NAPI `connect()` returns Duplex stream; data flows bidirectionally
|
- [x] NAPI `connect()` returns Duplex stream; data flows bidirectionally
|
||||||
- [ ] NAPI `serve()` accepts connections; `onConnection` emits Duplex streams
|
- [x] NAPI `serve()` accepts connections; `onConnection` emits Duplex streams
|
||||||
- [ ] NAPI key material from Buffer works (not just file paths)
|
- [x] NAPI key material from Buffer works (not just file paths)
|
||||||
- [ ] Feature flags: `tls`, `iroh`, `acme` correctly gate optional functionality
|
- [x] Feature flags: `tls`, `iroh`, `acme` correctly gate optional functionality
|
||||||
- [ ] Base build (`cargo build -p wraith-core` with no features) compiles and works
|
- [x] Base build (`cargo build -p wraith-core` with no features) compiles and works
|
||||||
- [ ] All tests pass: `cargo test --workspace`
|
- [x] All tests pass: `cargo test --workspace`
|
||||||
- [ ] NAPI tests pass: `cd crates/wraith-napi && npm test`
|
- [x] NAPI tests pass: `cd crates/wraith-napi && npm test`
|
||||||
- [ ] `cargo clippy --workspace` passes
|
- [x] `cargo clippy --workspace` passes
|
||||||
- [ ] No logging of tunnel destinations anywhere in the system
|
- [x] No logging of tunnel destinations anywhere in the system
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- docs/architecture/overview.md, docs/architecture/napi-and-pubsub.md
|
- docs/architecture/overview.md, docs/architecture/napi-and-pubsub.md
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
> To be filled by implementation agent
|
|
||||||
|
|
||||||
## Summary
|
## 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: review/server-and-client
|
id: review/server-and-client
|
||||||
name: Review server and client implementation — full SSH tunnel functionality
|
name: Review server and client implementation — full SSH tunnel functionality
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- meta/server-layer
|
- meta/server-layer
|
||||||
- meta/client-layer
|
- meta/client-layer
|
||||||
@@ -20,27 +20,27 @@ Verify end-to-end SSH tunnel flow: client connects → SOCKS5 proxy works → po
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] Server accepts SSH connections over TCP, TLS, iroh (via integration tests)
|
- [x] Server accepts SSH connections over TCP, TLS, iroh (via integration tests)
|
||||||
- [ ] Client establishes SSH sessions and runs SOCKS5 proxy
|
- [x] Client establishes SSH sessions and runs SOCKS5 proxy
|
||||||
- [ ] Channel proxy: direct TCP, SOCKS5 proxy, HTTP CONNECT proxy all work
|
- [x] Channel proxy: direct TCP, SOCKS5 proxy, HTTP CONNECT proxy all work
|
||||||
- [ ] Stealth mode: non-SSH gets nginx 404, SSH connects normally
|
- [x] Stealth mode: non-SSH gets nginx 404, SSH connects normally
|
||||||
- [ ] Rate limiting: connection limits enforced, auth attempt limits enforced
|
- [x] Rate limiting: connection limits enforced, auth attempt limits enforced
|
||||||
- [ ] Logging: structured `tracing::info!` events match ADR-013 format
|
- [x] Logging: structured `tracing::info!` events match ADR-013 format
|
||||||
- [ ] No logging of tunnel destinations (ADR-006)
|
- [x] No logging of tunnel destinations (ADR-006)
|
||||||
- [ ] Reconnection: transport failure → exponential backoff → reconnect → port forwards re-registered
|
- [x] Reconnection: transport failure → exponential backoff → reconnect → port forwards re-registered
|
||||||
- [ ] Reserved `wraith-` destinations routed to control channel, not TCP proxy
|
- [x] Reserved `wraith-` destinations routed to control channel, not TCP proxy
|
||||||
- [ ] Graceful shutdown works for both server and client
|
- [x] Graceful shutdown works for both server and client
|
||||||
- [ ] All tests pass: `cargo test --workspace`
|
- [x] All tests pass: `cargo test --workspace`
|
||||||
- [ ] `cargo clippy --workspace` passes
|
- [x] `cargo clippy --workspace` passes
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- docs/architecture/server.md, docs/architecture/client.md
|
- docs/architecture/server.md, docs/architecture/client.md
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
> To be filled by implementation agent
|
|
||||||
|
|
||||||
## Summary
|
## 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: server/serve-loop
|
id: server/serve-loop
|
||||||
name: Implement server accept loop, graceful shutdown, and ServeOptions config
|
name: Implement server accept loop, graceful shutdown, and ServeOptions config
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- server/handler
|
- server/handler
|
||||||
- server/channel-proxy
|
- server/channel-proxy
|
||||||
@@ -26,19 +26,19 @@ Implement the server's main accept loop and configuration. This ties together th
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `crates/wraith-core/src/server/mod.rs` re-exports all server components
|
- [x] `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`
|
- [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`
|
||||||
- [ ] `Server::new(opts: ServeOptions) -> Result<Server>` — creates server with bound acceptor, auth config, rate limiter
|
- [x] `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()`
|
- [x] `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()`
|
- [x] Stealth mode integration: if enabled, protocol detection before `run_stream()`
|
||||||
- [ ] Graceful shutdown: `Server::shutdown()` method and signal handler (SIGTERM/SIGINT)
|
- [x] Graceful shutdown: `Server::shutdown()` method and signal handler (SIGTERM/SIGINT)
|
||||||
- Stop accepting new connections
|
- Stop accepting new connections
|
||||||
- Send SSH disconnect to active sessions
|
- Send SSH disconnect to active sessions
|
||||||
- Wait for drain timeout (~2 seconds per session)
|
- Wait for drain timeout (~2 seconds per session)
|
||||||
- Forcibly terminate remaining connections
|
- Forcibly terminate remaining connections
|
||||||
- [ ] iroh mode: prints endpoint ID on startup
|
- [x] iroh mode: prints endpoint ID on startup
|
||||||
- [ ] `ServeOptions::key` and `ServeOptions::authorized_keys` accept `KeySource` (file or in-memory)
|
- [x] `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] Integration test: start server, client connects via mock transport, session works, shutdown completes
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -47,8 +47,20 @@ Implement the server's main accept loop and configuration. This ties together th
|
|||||||
|
|
||||||
## Notes
|
## 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
|
## 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
|
||||||
Reference in New Issue
Block a user