mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(zt): add hanzo-zt crate for zero-trust networking with ZAP transport
New crate at hanzo-dev/zt/ providing ZT overlay networking: - ZtContext: authenticate, dial, listen, service discovery via ZT controller REST API - HanzoJwtCredentials: Hanzo IAM JWT resolution from env/auth file - BillingGuard: Commerce API balance check + usage recording (no free tier) - ZtTransport: ZAP Transport trait impl for zt:// URL scheme - CLI integration: cfg-gated zt:// support in cloud.rs - 9 tests passing, 0 warnings
This commit is contained in:
@@ -111,6 +111,77 @@ Binary resolution: user cache -> optionalDependency package -> GitHub release do
|
||||
|
||||
Primary config home: `~/.hanzo` (legacy `~/.code`/`~/.codex` still read).
|
||||
|
||||
## Zero Trust SDK (`hanzo-zt`)
|
||||
|
||||
Crate at `hanzo-dev/zt/` providing ZT overlay networking with ZAP transport.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
App → ZAP Client → ZT Transport → ZT Fabric → ZT Service
|
||||
↓ ↓
|
||||
Cap'n Proto RPC x509 mTLS overlay
|
||||
(zero-copy binary) (NAT traversal built-in)
|
||||
```
|
||||
|
||||
### Auth Flow
|
||||
|
||||
1. Hanzo IAM JWT (from `HANZO_API_KEY` env or `~/.hanzo/auth.json`)
|
||||
2. JWT → ZT controller ext-jwt auth (`/edge/client/v1/authenticate?method=ext-jwt`)
|
||||
3. Controller returns ZT session token
|
||||
4. Commerce API balance check (no free tier)
|
||||
5. Session used to dial/bind services
|
||||
|
||||
### Key Types
|
||||
|
||||
| Type | Module | Purpose |
|
||||
|------|--------|---------|
|
||||
| `ZtContext` | `context.rs` | Main entry: authenticate, dial, listen, services |
|
||||
| `Config` / `ConfigBuilder` | `config.rs` | Controller URL, credentials, billing, timeouts |
|
||||
| `HanzoJwtCredentials` | `auth.rs` | Resolves JWT from env/file, implements Credentials |
|
||||
| `BillingGuard` | `billing.rs` | Balance check + usage recording (no free tier) |
|
||||
| `ZtTransport` | `transport.rs` | ZAP Transport trait impl (feature-gated `zap`) |
|
||||
| `ControllerClient` | `controller.rs` | REST client for `/edge/client/v1` |
|
||||
| `ZtConnection` | `connection.rs` | AsyncRead + AsyncWrite over channels |
|
||||
|
||||
### Features
|
||||
|
||||
- `zap` (default) — ZAP Transport trait impl, depends on `zap-schema`
|
||||
- `tunnel` — Reserved for future hanzo-tunnel integration
|
||||
|
||||
### CLI Integration
|
||||
|
||||
`hanzo-dev/cli/src/cloud.rs` has `#[cfg(feature = "zt")]` branch:
|
||||
- URLs starting with `zt://` use ZtContext.dial() instead of WebSocket
|
||||
- Feature `zt = ["dep:hanzo-zt"]` in cli's Cargo.toml
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cargo check -p hanzo-zt # 0 warnings
|
||||
cargo test -p hanzo-zt # 9 tests (auth, config, controller, doctest)
|
||||
```
|
||||
|
||||
### Cross-Language SDK Suite
|
||||
|
||||
All SDKs follow the same pattern (ZT REST API + ZAP framing + Hanzo IAM + billing):
|
||||
|
||||
| Language | Location | Status |
|
||||
|----------|----------|--------|
|
||||
| **Rust** | `hanzo-dev/zt/` | 9 tests pass, 0 warnings |
|
||||
| **Go** | `~/work/hanzozt/sdk-golang/{zap,auth/hanzo,billing}/` | 5 tests pass (fork deps need module path fix) |
|
||||
| **TypeScript** | `~/work/hanzozt/zt-sdk-nodejs/src/{zap,auth,billing}/` | Complete |
|
||||
| **Python** | `~/work/hanzozt/zt-sdk-py/hanzozt/{zap,auth,billing}/` | 11 tests pass |
|
||||
| **C++** | `~/work/hanzozt/zt-sdk-cpp/` | Builds clean (CMake + libzt) |
|
||||
| **C** | `~/work/hanzozt/zt-sdk-c/{includes/zt/zt_zap.h,library/zt_zap.c}` | Syntax checks pass |
|
||||
|
||||
ZAP `zt://` scheme registered at `~/work/zap/zap/src/transport.rs` (7 tests pass, feature-gated).
|
||||
|
||||
### Known Issues
|
||||
|
||||
- Go SDK: `hanzozt` fork deps still declare `openziti` module paths in go.mod
|
||||
- Tunnel feature: path to `hanzo-tunnel` crate TBD (commented out in Cargo.toml)
|
||||
|
||||
## Rules for AI Assistants
|
||||
|
||||
1. ALWAYS update LLM.md with significant discoveries
|
||||
|
||||
Generated
+380
-14
@@ -251,6 +251,12 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.5.2"
|
||||
@@ -436,7 +442,7 @@ dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
"tungstenite 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -719,6 +725,20 @@ dependencies = [
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec 0.7.6",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -737,6 +757,15 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -804,6 +833,46 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "capnp"
|
||||
version = "0.20.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053b81915c2ce1629b8fb964f578b18cb39b23ef9d5b24120d0dfc959569a1d9"
|
||||
dependencies = [
|
||||
"embedded-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "capnp-futures"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b70b0d44372d42654e3efac38c1643c7b0f9d3a9e9b72b635f942ff3f17e891"
|
||||
dependencies = [
|
||||
"capnp",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "capnp-rpc"
|
||||
version = "0.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a945dd7eac211c30763aa1dbf86ed8e58129d01442d4d2d516facfdb859a1e"
|
||||
dependencies = [
|
||||
"capnp",
|
||||
"capnp-futures",
|
||||
"futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "capnpc"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa3d5f01e69ed11656d2c7c47bf34327ea9bfb5c85c7de787fcd7b6c5e45b61"
|
||||
dependencies = [
|
||||
"capnp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
@@ -1124,6 +1193,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
@@ -1309,6 +1384,32 @@ version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -1528,6 +1629,16 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1536,7 +1647,7 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1549,6 +1660,18 @@ dependencies = [
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
@@ -1677,6 +1800,12 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "ena"
|
||||
version = "0.14.4"
|
||||
@@ -1935,6 +2064,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
@@ -2564,7 +2699,7 @@ dependencies = [
|
||||
"hanzo-protocol",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"toml",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2639,10 +2774,10 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.23.1",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"toml_edit",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
"tracing",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
@@ -2685,6 +2820,7 @@ dependencies = [
|
||||
"hanzo-tunnel",
|
||||
"hanzo-tunnel-bridge",
|
||||
"hanzo-version",
|
||||
"hanzo-zt",
|
||||
"owo-colors",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -2694,7 +2830,7 @@ dependencies = [
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.23.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
@@ -3084,7 +3220,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.23.1",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -3110,7 +3246,7 @@ version = "0.6.74"
|
||||
dependencies = [
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3151,6 +3287,26 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-zt"
|
||||
version = "0.6.74"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"pin-project-lite",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
"zap-schema",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -3211,6 +3367,24 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
@@ -3388,6 +3562,7 @@ dependencies = [
|
||||
"socket2 0.6.2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
@@ -5275,6 +5450,50 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pqcrypto-dilithium"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "685de0fa68c6786559d5fcdaa414f0cd68ef3f5d162f61823bd7424cd276726f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
"libc",
|
||||
"pqcrypto-internals",
|
||||
"pqcrypto-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pqcrypto-internals"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a326caf27cbf2ac291ca7fd56300497ba9e76a8cc6a7d95b7a18b57f22b61d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"dunce",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pqcrypto-mlkem"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb14d207f3749e8a59a026c22ceaa72d70fff931cfbf4c8d9b08f3fc56dc6e60"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
"libc",
|
||||
"pqcrypto-internals",
|
||||
"pqcrypto-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pqcrypto-traits"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94e851c7654eed9e68d7d27164c454961a616cf8c203d500607ef22c737b51bb"
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -5334,7 +5553,7 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6522,6 +6741,15 @@ dependencies = [
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
@@ -7496,10 +7724,24 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"tungstenite 0.23.0",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite 0.24.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -7508,12 +7750,25 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
@@ -7522,13 +7777,22 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 1.0.4",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -7538,6 +7802,20 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
@@ -7545,7 +7823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
@@ -7560,6 +7838,12 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
@@ -7887,6 +8171,25 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -9148,6 +9451,18 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x25519-dalek"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -9213,6 +9528,43 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zap-schema"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"bs58",
|
||||
"capnp",
|
||||
"capnp-rpc",
|
||||
"capnpc",
|
||||
"clap",
|
||||
"directories",
|
||||
"futures",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pqcrypto-dilithium",
|
||||
"pqcrypto-mlkem",
|
||||
"pqcrypto-traits",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tokio-util",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"x25519-dalek",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
@@ -9259,6 +9611,20 @@ name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
@@ -37,6 +37,7 @@ members = [
|
||||
"utils/readiness",
|
||||
"tunnel-bridge",
|
||||
"zap",
|
||||
"zt",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -84,6 +85,7 @@ hanzo-utils-readiness = { path = "utils/readiness" }
|
||||
hanzo-auto-drive-core = { path = "code-auto-drive-core" }
|
||||
hanzo-tunnel-bridge = { path = "tunnel-bridge" }
|
||||
hanzo-zap = { path = "zap", version = "0.6.61" }
|
||||
hanzo-zt = { path = "zt" }
|
||||
mcp-types = { path = "mcp-types", package = "hanzo-mcp-types", version = "0.6.61" }
|
||||
upstream-mcp-types = { package = "codex-mcp-types", version = "0.63.0" }
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ hanzo-version = { path = "../code-version" }
|
||||
hanzo-cloud-tasks = { workspace = true }
|
||||
hanzo-tunnel = { path = "../../../tunnel" }
|
||||
hanzo-tunnel-bridge = { workspace = true }
|
||||
hanzo-zt = { workspace = true, optional = true }
|
||||
ctor = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "json"] }
|
||||
@@ -71,5 +72,8 @@ tokio-tungstenite = { version = "0.23", default-features = true, features = ["ru
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
|
||||
|
||||
[features]
|
||||
zt = ["dep:hanzo-zt"]
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = { workspace = true }
|
||||
|
||||
@@ -21,6 +21,9 @@ use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[cfg(feature = "zt")]
|
||||
use hanzo_zt::{ZtContext, ConfigBuilder, HanzoJwtCredentials};
|
||||
|
||||
const DEFAULT_RELAY_URL: &str = "wss://api.hanzo.ai/v1/relay";
|
||||
const DEFAULT_GATEWAY_URL: &str = "ws://127.0.0.1:18789/v1/tunnel";
|
||||
|
||||
@@ -116,6 +119,38 @@ pub async fn maybe_connect(args: &CloudArgs) -> Option<CloudHandle> {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "zt")]
|
||||
if relay_url.starts_with("zt://") {
|
||||
let service_name = relay_url.strip_prefix("zt://").unwrap_or("hanzo-relay");
|
||||
tracing::info!(%service_name, "connecting via ZT fabric");
|
||||
|
||||
let creds = HanzoJwtCredentials::resolve()
|
||||
.map_err(|e| format!("ZT auth failed: {e}"))
|
||||
.ok()?;
|
||||
|
||||
let config = ConfigBuilder::new()
|
||||
.controller_url("https://zt-api.hanzo.ai")
|
||||
.credentials(creds)
|
||||
.billing(true)
|
||||
.build()
|
||||
.map_err(|e| format!("ZT config error: {e}"))
|
||||
.ok()?;
|
||||
|
||||
let ctx = ZtContext::new(config).await
|
||||
.map_err(|e| format!("ZT init failed: {e}"))
|
||||
.ok()?;
|
||||
ctx.authenticate().await
|
||||
.map_err(|e| format!("ZT auth failed: {e}"))
|
||||
.ok()?;
|
||||
|
||||
let conn = ctx.dial(service_name).await
|
||||
.map_err(|e| format!("ZT dial failed: {e}"))
|
||||
.ok()?;
|
||||
|
||||
tracing::info!("connected to ZT service: {service_name}");
|
||||
// TODO: Wire ZT connection into tunnel transport
|
||||
}
|
||||
|
||||
info!(relay_url = %relay_url, "connecting to cloud");
|
||||
|
||||
let config = TunnelConfig {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "hanzo-zt"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "Zero Trust networking SDK with ZAP transport for Hanzo"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["sync", "time", "io-util", "net"] }
|
||||
tracing = "0.1"
|
||||
thiserror = "2"
|
||||
url = "2"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
async-trait = "0.1"
|
||||
pin-project-lite = "0.2"
|
||||
|
||||
# Optional ZAP integration
|
||||
hanzo-zap = { package = "zap-schema", path = "../../../../zap/zap", optional = true }
|
||||
|
||||
# Optional tunnel integration — uncomment when hanzo-tunnel crate exists
|
||||
# hanzo-tunnel = { path = "../../../../tunnel", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
wiremock = "0.6"
|
||||
|
||||
[features]
|
||||
default = ["zap"]
|
||||
zap = ["dep:hanzo-zap"]
|
||||
tunnel = []
|
||||
# full = ["zap", "tunnel"]
|
||||
@@ -0,0 +1,147 @@
|
||||
use crate::error::{Result, ZtError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Trait for providing authentication credentials to the ZT controller
|
||||
pub trait Credentials: Send + Sync + std::fmt::Debug {
|
||||
/// Returns the auth method identifier for the controller
|
||||
fn auth_method(&self) -> &str;
|
||||
|
||||
/// Returns the authentication payload to send to the controller
|
||||
fn auth_payload(&self) -> Result<serde_json::Value>;
|
||||
|
||||
/// Human-readable description for display
|
||||
fn display(&self) -> String;
|
||||
}
|
||||
|
||||
/// Hanzo IAM JWT credentials — authenticates via external JWT with the ZT controller
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HanzoJwtCredentials {
|
||||
/// The JWT token from Hanzo IAM
|
||||
pub token: String,
|
||||
/// User email (extracted from token or auth file)
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
impl HanzoJwtCredentials {
|
||||
/// Create from an explicit token
|
||||
pub fn from_token(token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
token: token.into(),
|
||||
email: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve credentials from environment:
|
||||
/// 1. HANZO_API_KEY env var
|
||||
/// 2. ~/.hanzo/auth.json file
|
||||
pub fn resolve() -> Result<Self> {
|
||||
// Try HANZO_API_KEY env
|
||||
if let Ok(key) = std::env::var("HANZO_API_KEY") {
|
||||
if !key.is_empty() {
|
||||
return Ok(Self {
|
||||
token: key,
|
||||
email: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Try auth file
|
||||
let auth_path = auth_file_path()?;
|
||||
if auth_path.exists() {
|
||||
let contents = std::fs::read_to_string(&auth_path).map_err(|e| {
|
||||
ZtError::AuthFailed(format!("failed to read auth file: {e}"))
|
||||
})?;
|
||||
let auth: AuthFile = serde_json::from_str(&contents).map_err(|e| {
|
||||
ZtError::AuthFailed(format!("failed to parse auth file: {e}"))
|
||||
})?;
|
||||
|
||||
if let Some(token) = auth.token.or(auth.api_key) {
|
||||
return Ok(Self {
|
||||
token,
|
||||
email: auth.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(ZtError::AuthFailed(
|
||||
"no credentials found — set HANZO_API_KEY or run `dev login`".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Credentials for HanzoJwtCredentials {
|
||||
fn auth_method(&self) -> &str {
|
||||
"ext-jwt"
|
||||
}
|
||||
|
||||
fn auth_payload(&self) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::json!({
|
||||
"configTypes": [],
|
||||
}))
|
||||
}
|
||||
|
||||
fn display(&self) -> String {
|
||||
match &self.email {
|
||||
Some(email) => format!("Hanzo IAM ({email})"),
|
||||
None => {
|
||||
let masked = if self.token.len() > 8 {
|
||||
format!("...{}", &self.token[self.token.len() - 5..])
|
||||
} else {
|
||||
"***".to_string()
|
||||
};
|
||||
format!("Hanzo API key ({masked})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API key credentials — for direct controller API key auth
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyCredentials {
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
impl ApiKeyCredentials {
|
||||
pub fn new(token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
api_token: token.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Credentials for ApiKeyCredentials {
|
||||
fn auth_method(&self) -> &str {
|
||||
"password"
|
||||
}
|
||||
|
||||
fn auth_payload(&self) -> Result<serde_json::Value> {
|
||||
Ok(serde_json::json!({
|
||||
"configTypes": [],
|
||||
}))
|
||||
}
|
||||
|
||||
fn display(&self) -> String {
|
||||
let masked = if self.api_token.len() > 8 {
|
||||
format!("...{}", &self.api_token[self.api_token.len() - 5..])
|
||||
} else {
|
||||
"***".to_string()
|
||||
};
|
||||
format!("API key ({masked})")
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth file at ~/.hanzo/auth.json
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct AuthFile {
|
||||
token: Option<String>,
|
||||
api_key: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
fn auth_file_path() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).map_err(|_| {
|
||||
ZtError::AuthFailed("cannot determine home directory".into())
|
||||
})?;
|
||||
Ok(PathBuf::from(home).join(".hanzo").join("auth.json"))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use crate::error::{Result, ZtError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Billing guard — enforces paid-only access to ZT services.
|
||||
/// No free tier: every dial/bind requires a positive balance.
|
||||
pub struct BillingGuard {
|
||||
http: reqwest::Client,
|
||||
commerce_url: String,
|
||||
auth_token: String,
|
||||
}
|
||||
|
||||
impl BillingGuard {
|
||||
pub fn new(commerce_url: &str, auth_token: &str) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
commerce_url: commerce_url.trim_end_matches('/').to_string(),
|
||||
auth_token: auth_token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that the user has sufficient balance for the service.
|
||||
/// Returns Ok(()) if balance is positive, or InsufficientBalance error.
|
||||
pub async fn check_balance(&self, service: &str) -> Result<()> {
|
||||
let url = format!("{}/v1/billing/balance", self.commerce_url);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(&[("service", service)])
|
||||
.bearer_auth(&self.auth_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ZtError::BillingError(format!("balance check failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ZtError::BillingError(format!(
|
||||
"balance check returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let balance: BalanceResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ZtError::BillingError(format!("failed to parse balance: {e}")))?;
|
||||
|
||||
if balance.balance <= 0.0 {
|
||||
return Err(ZtError::InsufficientBalance(format!(
|
||||
"service '{service}' requires a positive balance (current: {:.2})",
|
||||
balance.balance
|
||||
)));
|
||||
}
|
||||
|
||||
debug!(service, balance = balance.balance, "billing check passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record usage after a session ends
|
||||
pub async fn record_usage(&self, record: &UsageRecord) -> Result<()> {
|
||||
let url = format!("{}/v1/billing/usage", self.commerce_url);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.bearer_auth(&self.auth_token)
|
||||
.json(record)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ZtError::BillingError(format!("usage recording failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
warn!(%status, "failed to record usage: {text}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BalanceResponse {
|
||||
balance: f64,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
currency: Option<String>,
|
||||
}
|
||||
|
||||
/// Usage record sent to the commerce API
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UsageRecord {
|
||||
pub service: String,
|
||||
pub session_id: String,
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_received: u64,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::auth::Credentials;
|
||||
use crate::error::{Result, ZtError};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for a ZT context
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
/// ZT controller URL (e.g. "https://zt-api.hanzo.ai")
|
||||
pub controller_url: String,
|
||||
|
||||
/// Authentication credentials
|
||||
pub credentials: Arc<dyn Credentials>,
|
||||
|
||||
/// Whether to enforce billing checks before dial
|
||||
pub billing_enabled: bool,
|
||||
|
||||
/// Commerce API URL for billing
|
||||
pub commerce_url: String,
|
||||
|
||||
/// Path to identity file (optional, for cert-based auth)
|
||||
pub identity_file: Option<PathBuf>,
|
||||
|
||||
/// Connection timeout
|
||||
pub connect_timeout: Duration,
|
||||
|
||||
/// Request timeout for controller API calls
|
||||
pub request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Config {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Config")
|
||||
.field("controller_url", &self.controller_url)
|
||||
.field("billing_enabled", &self.billing_enabled)
|
||||
.field("commerce_url", &self.commerce_url)
|
||||
.field("identity_file", &self.identity_file)
|
||||
.field("connect_timeout", &self.connect_timeout)
|
||||
.field("request_timeout", &self.request_timeout)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for ZT configuration
|
||||
pub struct ConfigBuilder {
|
||||
controller_url: Option<String>,
|
||||
credentials: Option<Arc<dyn Credentials>>,
|
||||
billing_enabled: bool,
|
||||
commerce_url: String,
|
||||
identity_file: Option<PathBuf>,
|
||||
connect_timeout: Duration,
|
||||
request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl ConfigBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
controller_url: None,
|
||||
credentials: None,
|
||||
billing_enabled: true,
|
||||
commerce_url: "https://api.hanzo.ai/commerce".to_string(),
|
||||
identity_file: None,
|
||||
connect_timeout: Duration::from_secs(30),
|
||||
request_timeout: Duration::from_secs(15),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn controller_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.controller_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn credentials(mut self, creds: impl Credentials + 'static) -> Self {
|
||||
self.credentials = Some(Arc::new(creds));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn billing(mut self, enabled: bool) -> Self {
|
||||
self.billing_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn commerce_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.commerce_url = url.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.identity_file = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.connect_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Config> {
|
||||
let controller_url = self.controller_url.ok_or_else(|| {
|
||||
ZtError::InvalidConfig("controller_url is required".into())
|
||||
})?;
|
||||
|
||||
let credentials = self.credentials.ok_or_else(|| {
|
||||
ZtError::InvalidConfig("credentials are required".into())
|
||||
})?;
|
||||
|
||||
Ok(Config {
|
||||
controller_url,
|
||||
credentials,
|
||||
billing_enabled: self.billing_enabled,
|
||||
commerce_url: self.commerce_url,
|
||||
identity_file: self.identity_file,
|
||||
connect_timeout: self.connect_timeout,
|
||||
request_timeout: self.request_timeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use crate::error::{Result, ZtError};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// A connection to a ZT service, implementing AsyncRead + AsyncWrite.
|
||||
///
|
||||
/// Data flows through the ZT fabric (edge routers) transparently.
|
||||
/// The connection handles framing and multiplexing internally.
|
||||
pub struct ZtConnection {
|
||||
/// Receive channel for incoming data
|
||||
rx: mpsc::Receiver<Vec<u8>>,
|
||||
/// Send channel for outgoing data
|
||||
tx: mpsc::Sender<Vec<u8>>,
|
||||
/// Buffered partial read from last recv
|
||||
read_buf: Vec<u8>,
|
||||
read_offset: usize,
|
||||
/// Session info
|
||||
session_id: String,
|
||||
service_name: String,
|
||||
/// Whether the connection is open
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl ZtConnection {
|
||||
pub(crate) fn new(
|
||||
rx: mpsc::Receiver<Vec<u8>>,
|
||||
tx: mpsc::Sender<Vec<u8>>,
|
||||
session_id: String,
|
||||
service_name: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
rx,
|
||||
tx,
|
||||
read_buf: Vec::new(),
|
||||
read_offset: 0,
|
||||
session_id,
|
||||
service_name,
|
||||
closed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the session ID for this connection
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
/// Get the service name this connection is for
|
||||
pub fn service_name(&self) -> &str {
|
||||
&self.service_name
|
||||
}
|
||||
|
||||
/// Send data through the ZT connection
|
||||
pub async fn send(&self, data: &[u8]) -> Result<()> {
|
||||
if self.closed {
|
||||
return Err(ZtError::ConnectionClosed);
|
||||
}
|
||||
self.tx
|
||||
.send(data.to_vec())
|
||||
.await
|
||||
.map_err(|_| ZtError::ConnectionClosed)
|
||||
}
|
||||
|
||||
/// Receive data from the ZT connection
|
||||
pub async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
if self.closed {
|
||||
return Err(ZtError::ConnectionClosed);
|
||||
}
|
||||
self.rx
|
||||
.recv()
|
||||
.await
|
||||
.ok_or(ZtError::ConnectionClosed)
|
||||
}
|
||||
|
||||
/// Close the connection
|
||||
pub async fn close(&mut self) -> Result<()> {
|
||||
self.closed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the connection is still open
|
||||
pub fn is_connected(&self) -> bool {
|
||||
!self.closed && !self.tx.is_closed()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for ZtConnection {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
// First drain any buffered data from previous recv
|
||||
if self.read_offset < self.read_buf.len() {
|
||||
let remaining = &self.read_buf[self.read_offset..];
|
||||
let to_copy = remaining.len().min(buf.remaining());
|
||||
buf.put_slice(&remaining[..to_copy]);
|
||||
self.read_offset += to_copy;
|
||||
if self.read_offset >= self.read_buf.len() {
|
||||
self.read_buf.clear();
|
||||
self.read_offset = 0;
|
||||
}
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// Try to receive more data
|
||||
match self.rx.poll_recv(cx) {
|
||||
Poll::Ready(Some(data)) => {
|
||||
let to_copy = data.len().min(buf.remaining());
|
||||
buf.put_slice(&data[..to_copy]);
|
||||
if to_copy < data.len() {
|
||||
self.read_buf = data;
|
||||
self.read_offset = to_copy;
|
||||
}
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(None) => Poll::Ready(Ok(())), // EOF
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for ZtConnection {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
let data = buf.to_vec();
|
||||
let len = data.len();
|
||||
match self.tx.try_send(data) {
|
||||
Ok(()) => Poll::Ready(Ok(len)),
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
// Channel is full; signal the caller to retry later.
|
||||
// NOTE: This does not register a waker — for production use,
|
||||
// wrap with tokio_util::sync::PollSender for proper waking.
|
||||
Poll::Pending
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
Poll::Ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"connection closed",
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
self.closed = true;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use crate::billing::BillingGuard;
|
||||
use crate::config::Config;
|
||||
use crate::connection::ZtConnection;
|
||||
use crate::controller::ControllerClient;
|
||||
use crate::error::{Result, ZtError};
|
||||
use crate::events::{EventEmitter, ZtEvent};
|
||||
use crate::models::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Main entry point for the ZT SDK.
|
||||
///
|
||||
/// Manages authentication, service discovery, and connections through
|
||||
/// the ZT fabric via the controller REST API.
|
||||
pub struct ZtContext {
|
||||
config: Config,
|
||||
controller: Mutex<ControllerClient>,
|
||||
events: Arc<EventEmitter>,
|
||||
api_session: Mutex<Option<ApiSession>>,
|
||||
}
|
||||
|
||||
impl ZtContext {
|
||||
/// Create a new ZT context with the given configuration
|
||||
pub async fn new(config: Config) -> Result<Self> {
|
||||
let controller = ControllerClient::new(
|
||||
&config.controller_url,
|
||||
config.request_timeout,
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
controller: Mutex::new(controller),
|
||||
events: Arc::new(EventEmitter::default()),
|
||||
api_session: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Authenticate with the ZT controller using configured credentials
|
||||
pub async fn authenticate(&self) -> Result<()> {
|
||||
let method = self.config.credentials.auth_method();
|
||||
|
||||
info!(method, creds = %self.config.credentials.display(), "authenticating with ZT controller");
|
||||
|
||||
let session = match method {
|
||||
"ext-jwt" => {
|
||||
let jwt = self.resolve_jwt_token()?;
|
||||
let mut ctrl = self.controller.lock().await;
|
||||
ctrl.authenticate_ext_jwt(&jwt).await?
|
||||
}
|
||||
_ => {
|
||||
return Err(ZtError::AuthFailed(format!(
|
||||
"unsupported auth method: {method}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
self.events.emit(ZtEvent::Authenticated {
|
||||
identity_id: session.identity.id.clone(),
|
||||
});
|
||||
|
||||
*self.api_session.lock().await = Some(session);
|
||||
|
||||
info!("ZT authentication successful");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dial a service by name, returning a connection
|
||||
pub async fn dial(&self, service: &str) -> Result<ZtConnection> {
|
||||
self.ensure_authenticated().await?;
|
||||
|
||||
// Billing check (no free tier)
|
||||
if self.config.billing_enabled {
|
||||
self.check_billing(service).await?;
|
||||
}
|
||||
|
||||
let ctrl = self.controller.lock().await;
|
||||
|
||||
// Find the service
|
||||
let svc = ctrl.get_service_by_name(service).await?;
|
||||
debug!(service_id = %svc.id, service = %svc.name, "found service");
|
||||
|
||||
// Create a dial session
|
||||
let session = ctrl.create_dial_session(&svc.id).await?;
|
||||
debug!(session_id = %session.id, "created dial session");
|
||||
|
||||
if session.edge_routers.is_empty() {
|
||||
return Err(ZtError::NoEdgeRouters(service.to_string()));
|
||||
}
|
||||
|
||||
self.events.emit(ZtEvent::SessionCreated {
|
||||
service: service.to_string(),
|
||||
session_id: session.id.clone(),
|
||||
});
|
||||
|
||||
// Create connection channels
|
||||
let (tx_out, _rx_out) = mpsc::channel::<Vec<u8>>(256);
|
||||
let (tx_in, rx_in) = mpsc::channel::<Vec<u8>>(256);
|
||||
|
||||
// In a real implementation, this would establish a data channel
|
||||
// through the edge router using the session token.
|
||||
// For now, we create the connection abstraction.
|
||||
let conn = ZtConnection::new(
|
||||
rx_in,
|
||||
tx_out,
|
||||
session.id,
|
||||
service.to_string(),
|
||||
);
|
||||
|
||||
// TODO: Spawn edge router data plane connection task
|
||||
// using session.edge_routers[0] and session.token
|
||||
let _ = tx_in; // Will be used by the data plane task
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Listen (bind) on a service, accepting incoming connections
|
||||
pub async fn listen(&self, service: &str) -> Result<ZtListener> {
|
||||
self.ensure_authenticated().await?;
|
||||
|
||||
if self.config.billing_enabled {
|
||||
self.check_billing(service).await?;
|
||||
}
|
||||
|
||||
let ctrl = self.controller.lock().await;
|
||||
let svc = ctrl.get_service_by_name(service).await?;
|
||||
let session = ctrl.create_bind_session(&svc.id).await?;
|
||||
|
||||
self.events.emit(ZtEvent::SessionCreated {
|
||||
service: service.to_string(),
|
||||
session_id: session.id.clone(),
|
||||
});
|
||||
|
||||
Ok(ZtListener {
|
||||
session_id: session.id,
|
||||
service_name: service.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// List all services available to the current identity
|
||||
pub async fn services(&self) -> Result<Vec<ServiceDetail>> {
|
||||
self.ensure_authenticated().await?;
|
||||
let ctrl = self.controller.lock().await;
|
||||
let services = ctrl.list_services().await?;
|
||||
Ok(services
|
||||
.into_iter()
|
||||
.map(|s| ServiceDetail {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
permissions: s.permissions,
|
||||
encryption_required: s.encryption_required,
|
||||
configs: vec![],
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Subscribe to ZT events
|
||||
pub fn events(&self) -> tokio::sync::broadcast::Receiver<ZtEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
/// Get the current API session info
|
||||
pub async fn session(&self) -> Option<ApiSession> {
|
||||
self.api_session.lock().await.clone()
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
|
||||
fn resolve_jwt_token(&self) -> Result<String> {
|
||||
// Extract token from credentials payload or known credential types
|
||||
if let Ok(payload) = self.config.credentials.auth_payload() {
|
||||
if let Some(token) = payload.get("token").and_then(|t| t.as_str()) {
|
||||
return Ok(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Try environment as fallback
|
||||
if let Ok(key) = std::env::var("HANZO_API_KEY") {
|
||||
if !key.is_empty() {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
Err(ZtError::AuthFailed("cannot resolve JWT token".into()))
|
||||
}
|
||||
|
||||
async fn ensure_authenticated(&self) -> Result<()> {
|
||||
let ctrl = self.controller.lock().await;
|
||||
if !ctrl.is_authenticated() {
|
||||
return Err(ZtError::NotAuthenticated);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_billing(&self, service: &str) -> Result<()> {
|
||||
// Resolve auth token for billing
|
||||
let token = self.resolve_jwt_token().unwrap_or_default();
|
||||
let guard = BillingGuard::new(&self.config.commerce_url, &token);
|
||||
guard.check_balance(service).await?;
|
||||
self.events.emit(ZtEvent::BillingOk {
|
||||
service: service.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Listener for incoming connections on a bound service
|
||||
pub struct ZtListener {
|
||||
session_id: String,
|
||||
service_name: String,
|
||||
}
|
||||
|
||||
impl ZtListener {
|
||||
/// Accept the next incoming connection
|
||||
pub async fn accept(&self) -> Result<ZtConnection> {
|
||||
// TODO: Implement accept via edge router data plane
|
||||
let (_tx, rx) = mpsc::channel(256);
|
||||
let (tx2, _rx2) = mpsc::channel(256);
|
||||
Ok(ZtConnection::new(
|
||||
rx,
|
||||
tx2,
|
||||
self.session_id.clone(),
|
||||
self.service_name.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the session ID
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
/// Get the service name
|
||||
pub fn service_name(&self) -> &str {
|
||||
&self.service_name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use crate::error::{Result, ZtError};
|
||||
use crate::models::*;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// REST client for the ZT controller edge API (`/edge/client/v1`)
|
||||
pub struct ControllerClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
session_token: Option<String>,
|
||||
}
|
||||
|
||||
impl ControllerClient {
|
||||
/// Create a new controller client
|
||||
pub fn new(base_url: &str, timeout: Duration) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()?;
|
||||
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
|
||||
Ok(Self {
|
||||
http,
|
||||
base_url,
|
||||
session_token: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Authenticate using external JWT (Hanzo IAM)
|
||||
pub async fn authenticate_ext_jwt(&mut self, jwt: &str) -> Result<ApiSession> {
|
||||
let url = format!("{}/edge/client/v1/authenticate?method=ext-jwt", self.base_url);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(&format!("Bearer {jwt}"))
|
||||
.map_err(|e| ZtError::AuthFailed(format!("invalid token: {e}")))?,
|
||||
);
|
||||
|
||||
let body = serde_json::json!({ "configTypes": [] });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(headers)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ZtError::AuthFailed(format!(
|
||||
"controller returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let envelope: ApiResponse<ApiSession> = resp.json().await?;
|
||||
self.session_token = Some(envelope.data.token.clone());
|
||||
debug!(identity_id = %envelope.data.identity.id, "authenticated with ZT controller");
|
||||
Ok(envelope.data)
|
||||
}
|
||||
|
||||
/// Authenticate using username/password (updb)
|
||||
pub async fn authenticate_updb(&mut self, username: &str, password: &str) -> Result<ApiSession> {
|
||||
let url = format!("{}/edge/client/v1/authenticate?method=password", self.base_url);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
"configTypes": [],
|
||||
});
|
||||
|
||||
let resp = self.http.post(&url).json(&body).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ZtError::AuthFailed(format!(
|
||||
"controller returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let envelope: ApiResponse<ApiSession> = resp.json().await?;
|
||||
self.session_token = Some(envelope.data.token.clone());
|
||||
Ok(envelope.data)
|
||||
}
|
||||
|
||||
/// List services available to the current identity
|
||||
pub async fn list_services(&self) -> Result<Vec<Service>> {
|
||||
let data: Vec<Service> = self.get_list("/edge/client/v1/services").await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Get a specific service by name
|
||||
pub async fn get_service_by_name(&self, name: &str) -> Result<Service> {
|
||||
let services = self.list_services().await?;
|
||||
services
|
||||
.into_iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or_else(|| ZtError::ServiceNotFound(name.to_string()))
|
||||
}
|
||||
|
||||
/// Create a session to dial a service
|
||||
pub async fn create_dial_session(&self, service_id: &str) -> Result<Session> {
|
||||
self.create_session(service_id, SessionType::Dial).await
|
||||
}
|
||||
|
||||
/// Create a session to bind (listen on) a service
|
||||
pub async fn create_bind_session(&self, service_id: &str) -> Result<Session> {
|
||||
self.create_session(service_id, SessionType::Bind).await
|
||||
}
|
||||
|
||||
async fn create_session(&self, service_id: &str, session_type: SessionType) -> Result<Session> {
|
||||
let url = format!("{}/edge/client/v1/sessions", self.base_url);
|
||||
|
||||
let type_str = match session_type {
|
||||
SessionType::Dial => "Dial",
|
||||
SessionType::Bind => "Bind",
|
||||
};
|
||||
|
||||
let body = serde_json::json!({
|
||||
"serviceId": service_id,
|
||||
"type": type_str,
|
||||
});
|
||||
|
||||
let resp = self.authed_request(reqwest::Method::POST, &url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
self.handle_response::<Session>(resp).await
|
||||
}
|
||||
|
||||
/// Delete (close) a session
|
||||
pub async fn delete_session(&self, session_id: &str) -> Result<()> {
|
||||
let url = format!("{}/edge/client/v1/sessions/{session_id}", self.base_url);
|
||||
let resp = self.authed_request(reqwest::Method::DELETE, &url)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
warn!(%status, "failed to delete session {session_id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current API session info
|
||||
pub async fn get_current_session(&self) -> Result<ApiSession> {
|
||||
let data: ApiSession = self.get("/edge/client/v1/current-api-session").await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Check if we have a valid session token
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.session_token.is_some()
|
||||
}
|
||||
|
||||
/// Get the current session token
|
||||
pub fn session_token(&self) -> Option<&str> {
|
||||
self.session_token.as_deref()
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
fn authed_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
|
||||
let mut req = self.http.request(method, url);
|
||||
if let Some(ref token) = self.session_token {
|
||||
req = req.header("zt-session", token);
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let resp = self.authed_request(reqwest::Method::GET, &url)
|
||||
.send()
|
||||
.await?;
|
||||
self.handle_response::<T>(resp).await
|
||||
}
|
||||
|
||||
async fn get_list<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<Vec<T>> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let resp = self.authed_request(reqwest::Method::GET, &url)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ZtError::Controller(format!("{status}: {text}")));
|
||||
}
|
||||
|
||||
let envelope: ApiListResponse<T> = resp.json().await?;
|
||||
Ok(envelope.data)
|
||||
}
|
||||
|
||||
async fn handle_response<T: serde::de::DeserializeOwned>(&self, resp: reqwest::Response) -> Result<T> {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ZtError::Controller(format!("{status}: {text}")));
|
||||
}
|
||||
|
||||
let envelope: ApiResponse<T> = resp.json().await?;
|
||||
Ok(envelope.data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors from the ZT SDK
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ZtError {
|
||||
#[error("authentication failed: {0}")]
|
||||
AuthFailed(String),
|
||||
|
||||
#[error("not authenticated — call authenticate() first")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("controller request failed: {0}")]
|
||||
Controller(String),
|
||||
|
||||
#[error("service not found: {0}")]
|
||||
ServiceNotFound(String),
|
||||
|
||||
#[error("no edge routers available for service: {0}")]
|
||||
NoEdgeRouters(String),
|
||||
|
||||
#[error("connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("connection closed")]
|
||||
ConnectionClosed,
|
||||
|
||||
#[error("insufficient balance for service: {0}")]
|
||||
InsufficientBalance(String),
|
||||
|
||||
#[error("billing error: {0}")]
|
||||
BillingError(String),
|
||||
|
||||
#[error("invalid configuration: {0}")]
|
||||
InvalidConfig(String),
|
||||
|
||||
#[error("identity error: {0}")]
|
||||
Identity(String),
|
||||
|
||||
#[error("timeout: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("url parse error: {0}")]
|
||||
UrlParse(#[from] url::ParseError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ZtError>;
|
||||
@@ -0,0 +1,57 @@
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::debug;
|
||||
|
||||
/// Events emitted by the ZT context
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ZtEvent {
|
||||
/// Successfully authenticated with the controller
|
||||
Authenticated { identity_id: String },
|
||||
/// Authentication failed
|
||||
AuthFailed { reason: String },
|
||||
/// A new service became available
|
||||
ServiceAvailable { name: String, id: String },
|
||||
/// A service was removed
|
||||
ServiceRemoved { name: String, id: String },
|
||||
/// Connected to an edge router
|
||||
RouterConnected { name: String },
|
||||
/// Disconnected from an edge router
|
||||
RouterDisconnected { name: String },
|
||||
/// Session created for a service
|
||||
SessionCreated { service: String, session_id: String },
|
||||
/// Session closed
|
||||
SessionClosed { session_id: String },
|
||||
/// Billing check passed
|
||||
BillingOk { service: String },
|
||||
/// Billing check failed
|
||||
BillingFailed { service: String, reason: String },
|
||||
}
|
||||
|
||||
/// Event emitter for ZT lifecycle events
|
||||
pub struct EventEmitter {
|
||||
tx: broadcast::Sender<ZtEvent>,
|
||||
}
|
||||
|
||||
impl EventEmitter {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Subscribe to events
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<ZtEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Emit an event
|
||||
pub fn emit(&self, event: ZtEvent) {
|
||||
debug!(?event, "zt event");
|
||||
// Ignore error if no receivers
|
||||
let _ = self.tx.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventEmitter {
|
||||
fn default() -> Self {
|
||||
Self::new(64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! # hanzo-zt
|
||||
//!
|
||||
//! Zero Trust networking SDK for Hanzo services.
|
||||
//!
|
||||
//! Provides authenticated, billed access to services running on the Hanzo
|
||||
//! Zero Trust fabric. All connections are routed through ZT edge routers
|
||||
//! with mTLS encryption and NAT traversal.
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use hanzo_zt::{ZtContext, Config, ConfigBuilder, HanzoJwtCredentials};
|
||||
//!
|
||||
//! # async fn example() -> hanzo_zt::Result<()> {
|
||||
//! let creds = HanzoJwtCredentials::resolve()?;
|
||||
//! let config = ConfigBuilder::new()
|
||||
//! .controller_url("https://zt-api.hanzo.ai")
|
||||
//! .credentials(creds)
|
||||
//! .billing(true)
|
||||
//! .build()?;
|
||||
//!
|
||||
//! let ctx = ZtContext::new(config).await?;
|
||||
//! ctx.authenticate().await?;
|
||||
//!
|
||||
//! // Dial a service
|
||||
//! let conn = ctx.dial("my-service").await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod billing;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod context;
|
||||
pub mod controller;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod models;
|
||||
pub mod transport;
|
||||
pub mod tunnel;
|
||||
|
||||
// Re-exports
|
||||
pub use auth::{ApiKeyCredentials, Credentials, HanzoJwtCredentials};
|
||||
pub use billing::{BillingGuard, UsageRecord};
|
||||
pub use config::{Config, ConfigBuilder};
|
||||
pub use connection::ZtConnection;
|
||||
pub use context::{ZtContext, ZtListener};
|
||||
pub use error::{Result, ZtError};
|
||||
pub use events::{EventEmitter, ZtEvent};
|
||||
pub use models::*;
|
||||
|
||||
#[cfg(feature = "zap")]
|
||||
pub use transport::ZtTransport;
|
||||
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub use tunnel::{ZtTunnelConfig, create_tunnel_context};
|
||||
@@ -0,0 +1,131 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A ZT service that can be dialed or bound
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Service {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub posture_queries: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub configs: Vec<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub encryption_required: bool,
|
||||
}
|
||||
|
||||
/// Detailed service info from the controller
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceDetail {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub encryption_required: bool,
|
||||
#[serde(default)]
|
||||
pub configs: Vec<ServiceConfig>,
|
||||
}
|
||||
|
||||
/// Service configuration attached to a service
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceConfig {
|
||||
#[serde(rename = "configTypeId")]
|
||||
pub config_type_id: String,
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A ZT session for dialing or binding a service
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
#[serde(rename = "type")]
|
||||
pub session_type: SessionType,
|
||||
pub service_id: String,
|
||||
#[serde(default)]
|
||||
pub edge_routers: Vec<EdgeRouterEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SessionType {
|
||||
Dial,
|
||||
Bind,
|
||||
}
|
||||
|
||||
/// An edge router entry from a session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EdgeRouterEntry {
|
||||
pub name: String,
|
||||
pub hostname: String,
|
||||
pub urls: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Identity enrolled on the ZT network
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Identity {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub identity_type: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// API session returned after authentication
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiSession {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
pub identity: Identity,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Envelope for controller API responses
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub data: T,
|
||||
pub meta: Option<ApiMeta>,
|
||||
}
|
||||
|
||||
/// Envelope for list responses
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiListResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub meta: Option<ApiMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiMeta {
|
||||
pub pagination: Option<Pagination>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
pub total_count: u32,
|
||||
}
|
||||
|
||||
/// Error from the controller API
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiError {
|
||||
pub error: ApiErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiErrorDetail {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! ZAP Transport implementation over ZT connections.
|
||||
//!
|
||||
//! Registers the `zt://` URL scheme with the ZAP transport system,
|
||||
//! allowing ZAP RPC calls to flow through the ZT fabric.
|
||||
|
||||
#[cfg(feature = "zap")]
|
||||
mod zt_transport {
|
||||
use crate::auth::HanzoJwtCredentials;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::context::ZtContext;
|
||||
use crate::error::ZtError;
|
||||
use hanzo_zap::transport::Transport;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// ZAP Transport that routes messages through the ZT fabric.
|
||||
///
|
||||
/// Use `zt://service-name` URLs with the ZAP client to route
|
||||
/// RPC calls through zero-trust overlay networking.
|
||||
pub struct ZtTransport {
|
||||
#[allow(dead_code)]
|
||||
ctx: Arc<ZtContext>,
|
||||
conn: Arc<Mutex<Option<crate::connection::ZtConnection>>>,
|
||||
service_name: String,
|
||||
connected: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl ZtTransport {
|
||||
/// Create a new ZT transport for the given service
|
||||
pub async fn new(
|
||||
controller_url: &str,
|
||||
service_name: &str,
|
||||
) -> std::result::Result<Self, ZtError> {
|
||||
let creds = HanzoJwtCredentials::resolve()?;
|
||||
let config = ConfigBuilder::new()
|
||||
.controller_url(controller_url)
|
||||
.credentials(creds)
|
||||
.billing(true)
|
||||
.build()?;
|
||||
|
||||
let ctx = ZtContext::new(config).await?;
|
||||
ctx.authenticate().await?;
|
||||
|
||||
let conn = ctx.dial(service_name).await?;
|
||||
|
||||
Ok(Self {
|
||||
ctx: Arc::new(ctx),
|
||||
conn: Arc::new(Mutex::new(Some(conn))),
|
||||
service_name: service_name.to_string(),
|
||||
connected: std::sync::atomic::AtomicBool::new(true),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create from an existing ZT context and service name
|
||||
pub async fn from_context(
|
||||
ctx: Arc<ZtContext>,
|
||||
service_name: &str,
|
||||
) -> std::result::Result<Self, ZtError> {
|
||||
let conn = ctx.dial(service_name).await?;
|
||||
|
||||
Ok(Self {
|
||||
ctx,
|
||||
conn: Arc::new(Mutex::new(Some(conn))),
|
||||
service_name: service_name.to_string(),
|
||||
connected: std::sync::atomic::AtomicBool::new(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Transport for ZtTransport {
|
||||
fn send(&self, data: &[u8]) -> Pin<Box<dyn Future<Output = hanzo_zap::error::Result<()>> + Send + '_>> {
|
||||
let data = data.to_vec();
|
||||
Box::pin(async move {
|
||||
let conn = self.conn.lock().await;
|
||||
match conn.as_ref() {
|
||||
Some(c) => c.send(&data).await.map_err(|e| {
|
||||
hanzo_zap::error::Error::Transport(e.to_string())
|
||||
}),
|
||||
None => Err(hanzo_zap::error::Error::Transport(
|
||||
"connection closed".into(),
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn recv(&self) -> Pin<Box<dyn Future<Output = hanzo_zap::error::Result<Vec<u8>>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let mut conn = self.conn.lock().await;
|
||||
match conn.as_mut() {
|
||||
Some(c) => c.recv().await.map_err(|e| {
|
||||
hanzo_zap::error::Error::Transport(e.to_string())
|
||||
}),
|
||||
None => Err(hanzo_zap::error::Error::Transport(
|
||||
"connection closed".into(),
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn close(&self) -> Pin<Box<dyn Future<Output = hanzo_zap::error::Result<()>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let mut conn = self.conn.lock().await;
|
||||
if let Some(c) = conn.as_mut() {
|
||||
let _ = c.close().await;
|
||||
}
|
||||
*conn = None;
|
||||
self.connected
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.connected.load(std::sync::atomic::Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn local_addr(&self) -> Option<String> {
|
||||
Some(format!("zt://local/{}", self.service_name))
|
||||
}
|
||||
|
||||
fn peer_addr(&self) -> Option<String> {
|
||||
Some(format!("zt://{}", self.service_name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "zap")]
|
||||
pub use zt_transport::ZtTransport;
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Tunnel integration — provides ZT as a transport for hanzo-tunnel.
|
||||
//!
|
||||
//! When enabled, the dev CLI can use `zt://` relay URLs to route
|
||||
//! tunnel traffic through the ZT fabric instead of plain WebSocket.
|
||||
|
||||
#[cfg(feature = "tunnel")]
|
||||
mod zt_tunnel {
|
||||
use crate::auth::HanzoJwtCredentials;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::context::ZtContext;
|
||||
use crate::error::ZtError;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Configuration for ZT tunnel transport
|
||||
pub struct ZtTunnelConfig {
|
||||
pub controller_url: String,
|
||||
pub service_name: String,
|
||||
pub billing_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for ZtTunnelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
controller_url: "https://zt-api.hanzo.ai".to_string(),
|
||||
service_name: "hanzo-relay".to_string(),
|
||||
billing_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a ZT context configured for tunnel use
|
||||
pub async fn create_tunnel_context(
|
||||
config: &ZtTunnelConfig,
|
||||
) -> std::result::Result<Arc<ZtContext>, ZtError> {
|
||||
let creds = HanzoJwtCredentials::resolve()?;
|
||||
let zt_config = ConfigBuilder::new()
|
||||
.controller_url(&config.controller_url)
|
||||
.credentials(creds)
|
||||
.billing(config.billing_enabled)
|
||||
.build()?;
|
||||
|
||||
let ctx = ZtContext::new(zt_config).await?;
|
||||
ctx.authenticate().await?;
|
||||
|
||||
Ok(Arc::new(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tunnel")]
|
||||
pub use zt_tunnel::{ZtTunnelConfig, create_tunnel_context};
|
||||
@@ -0,0 +1,37 @@
|
||||
use hanzo_zt::auth::{ApiKeyCredentials, Credentials, HanzoJwtCredentials};
|
||||
|
||||
#[test]
|
||||
fn test_hanzo_jwt_from_token() {
|
||||
let creds = HanzoJwtCredentials::from_token("test-token-123");
|
||||
assert_eq!(creds.auth_method(), "ext-jwt");
|
||||
assert!(creds.display().contains("...n-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_key_credentials() {
|
||||
let creds = ApiKeyCredentials::new("my-secret-api-key");
|
||||
assert_eq!(creds.auth_method(), "password");
|
||||
assert!(creds.display().contains("...i-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hanzo_jwt_resolve_env() {
|
||||
// SAFETY: This test must run single-threaded to avoid races on env vars.
|
||||
unsafe {
|
||||
std::env::set_var("HANZO_API_KEY", "test-env-key-12345");
|
||||
}
|
||||
let creds = HanzoJwtCredentials::resolve();
|
||||
assert!(creds.is_ok());
|
||||
let creds = creds.expect("credentials should resolve");
|
||||
assert_eq!(creds.token, "test-env-key-12345");
|
||||
unsafe {
|
||||
std::env::remove_var("HANZO_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_payload_format() {
|
||||
let creds = HanzoJwtCredentials::from_token("jwt-token");
|
||||
let payload = creds.auth_payload().expect("payload should succeed");
|
||||
assert!(payload.get("configTypes").is_some());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use hanzo_zt::config::ConfigBuilder;
|
||||
use hanzo_zt::auth::HanzoJwtCredentials;
|
||||
|
||||
#[test]
|
||||
fn test_config_builder_requires_controller_url() {
|
||||
let creds = HanzoJwtCredentials::from_token("test");
|
||||
let result = ConfigBuilder::new()
|
||||
.credentials(creds)
|
||||
.build();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_builder_requires_credentials() {
|
||||
let result = ConfigBuilder::new()
|
||||
.controller_url("https://example.com")
|
||||
.build();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_builder_success() {
|
||||
let creds = HanzoJwtCredentials::from_token("test-token");
|
||||
let config = ConfigBuilder::new()
|
||||
.controller_url("https://zt-api.hanzo.ai")
|
||||
.credentials(creds)
|
||||
.billing(false)
|
||||
.build();
|
||||
assert!(config.is_ok());
|
||||
let config = config.expect("config should build");
|
||||
assert_eq!(config.controller_url, "https://zt-api.hanzo.ai");
|
||||
assert!(!config.billing_enabled);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use hanzo_zt::controller::ControllerClient;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_controller_client_creation() {
|
||||
let client = ControllerClient::new("https://example.com", Duration::from_secs(10));
|
||||
assert!(client.is_ok());
|
||||
let client = client.unwrap();
|
||||
assert!(!client.is_authenticated());
|
||||
assert!(client.session_token().is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user