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:
2026-02-19 14:26:40 -08:00
parent 8e91514bb4
commit f41500a5f4
21 changed files with 2071 additions and 14 deletions
+71
View File
@@ -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
+380 -14
View File
@@ -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"
+2
View File
@@ -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" }
+4
View File
@@ -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 }
+35
View File
@@ -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 {
+36
View File
@@ -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"]
+147
View File
@@ -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"))
}
+99
View File
@@ -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,
}
+129
View File
@@ -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()
}
}
+157
View File
@@ -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(()))
}
}
+236
View File
@@ -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
}
}
+214
View File
@@ -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)
}
}
+55
View File
@@ -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>;
+57
View File
@@ -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)
}
}
+57
View File
@@ -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};
+131
View File
@@ -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,
}
+130
View File
@@ -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;
+50
View File
@@ -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};
+37
View File
@@ -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());
}
+33
View File
@@ -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);
}
+11
View File
@@ -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());
}