commit 7cea9afa3733e89fb470109a539d1f11cbc0a758 Author: tsunagi Date: Mon Sep 21 00:10:07 2026 +0100 Proof-of-concept mesh agent library over iroh Working library with real iroh connections, not an interface sketch: - persistent device identity in state.sqlite, stable across restarts - deterministic network space derived from name + secret via HKDF-SHA256, with frozen labels and unambiguous length-prefixed encoding - replaceable discovery returning unverified candidates only; static bootstrap, in-memory test backend and a composite - real iroh connections plus an explicit mutual membership proof: HMAC-SHA256 over a role-separated transcript bound to the TLS exporter, the network id and both endpoint identities - small versioned control protocol: handshake, announcement, ping/pong - multiple networks per agent with enforced isolation - automatic reconnect with bounded backoff and jitter - mandatory state vs disposable cache, with a real directory ownership lock - status snapshots, event stream and honest diagnostics 47 integration and unit tests cover the required scenarios offline on loopback. Snapshots, revocations and WireGuard are designed for and documented, not implemented. Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..afdb6c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + # The default test set must not need the internet, a DHT, a public relay, + # administrator rights or changes to OS network settings. + RUST_BACKTRACE: 1 + +jobs: + check: + name: fmt + clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo clippy --locked --workspace --all-targets -- -D warnings + + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --locked --workspace --all-targets + - name: run the example + run: cargo run --locked --example two_agents + + docs: + name: rustdoc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo doc --locked --workspace --no-deps + env: + RUSTDOCFLAGS: -D warnings + + msrv: + name: minimum supported Rust version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.91.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo check --locked --workspace --all-targets diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dbd564f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# AGENTS.md + +Rules for anyone — human or automated — changing this repository. Read this +before touching the code. Do not restate the full design here; follow the links. + +## What this project is + +A proof-of-concept agent library for small private mesh networks. A user +supplies a network name and one shared secret; agents derive the same network +space independently, find each other, prove membership and exchange control +messages. Scope and non-scope are in [README.md](README.md). + +There is no central server, no owner, no registration and no majority vote. +Design accordingly: a majority is not a root of trust. + +## Architectural boundaries + +Keep these separate. Crossing them is the main thing to review for. + +- **Control plane vs data plane.** iroh carries control messages only. User IP + traffic is never tunnelled through it. The core must never parse a plugin's + payload — see `src/dataplane.rs`. Do not advertise WireGuard as available + transport before it exists; tests use an explicitly test-only capability id. +- **Device identity vs network identity.** The iroh endpoint id is the device's + public key. `NetworkId` is derived from name + secret only. Never conflate + them, and never let one change the other. +- **Discovery vs authentication.** Discovery returns *unverified candidates*. + It never authenticates, never carries control messages and never mutates + agent state. Membership is decided only by the handshake. +- **Mandatory state vs disposable cache.** `state.sqlite` is never silently + reset: damage is a hard error. `cache.sqlite` may be deleted at any time and + is recreated. A stale cache must never bypass identity or network + authentication. +- **Candidate vs observed address vs verified path.** Report these as three + different things. A missing value is `None`, never invented. + +## Rules that are not negotiable + +1. **Tests come first.** Every substantial change ships with tests for the + normal path *and* the important failures. See [docs/testing.md](docs/testing.md). + Do not add tests for getters or to move a coverage number. +2. **Never change `IDENTITY_SCHEME`, the derivation labels, or the transcript + encoding** in `src/identity/network.rs` and `src/proto/handshake.rs` without + treating it as an incompatible protocol change. Bumping the crate version or + the control protocol version must not change an existing `NetworkId`. +3. **Secrets never leak.** Not into logs, not into `Debug`, not into status + output, not into anything exported to the network. `NetworkSecret` and + derived keys redact themselves and zeroize; keep it that way. +4. **No panics on untrusted input.** No `unwrap`, `expect` or `panic` on + anything that came off the network. Clippy enforces this at warn level in + the library; tests opt out explicitly at the top of each file. +5. **Bounds before allocation.** Frame lengths are checked against + `Limits::max_frame_len` before a buffer is allocated. Every string, list and + queue has a limit in `src/config.rs`. +6. **Failure is contained.** A bad signature, wrong secret, malformed packet or + unknown version rejects one message or one session. It never stops another + network and never stops the agent. There is no irreversible global error + flag. A data plane failure must never stop the control plane. +7. **The library owns no globals.** No global tokio runtime, no global tracing + subscriber, no signal handlers, no `fork`, no `process::exit`, no global + mutable state. Several agents must run in one test process. +8. **SQLite never blocks the executor.** All database work goes through + `spawn_blocking`. Never hold a transaction or a database lock across a + network `await`. When signed records land, writing the record and bumping + the author's own counter must be one transaction, committed **before** + publishing to the network. +9. **Shutdown is bounded.** A peer that stops reading must not be able to hold + up shutdown. Wind tasks down with a grace period and then abort. +10. **Nothing from a remote announcement becomes a shell command, a filesystem + path or an OS setting.** The agent never touches interfaces or OS settings + it did not create. + +## Where things live + +| path | responsibility | +|---------------------|----------------| +| `src/identity/` | device identity; deterministic network space identity and derived keys | +| `src/storage/` | `state.sqlite`, `cache.sqlite`, directory ownership lock | +| `src/discovery.rs` | candidate sources; test and static backends | +| `src/proto/` | framing, message formats, membership handshake | +| `src/net.rs` | iroh endpoint adapter and observability snapshots | +| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status | +| `src/dataplane.rs` | the contract future IP plugins implement | +| `tests/` | integration tests; `tests/common/` is the shared harness | + +Add abstractions only at real substitution or testing boundaries. Do not add a +trait per struct. Prefer one crate with clear modules over many small crates. + +## Testing rules + +- Use real iroh endpoints on loopback, real handshakes, real SQLite in + temporary directories, and independent agent instances. +- Discovery may be substituted. **iroh, authentication, message passing and + persistent storage may not be.** +- No arbitrary multi-second `sleep` as the primary synchronisation. Wait for a + specific event or condition under one overall deadline (`wait_event`, + `wait_until`). `settle()` exists only for asserting that something did *not* + happen. +- The default suite must pass with no internet, no DHT, no public relay, no + administrator rights and no changes to OS network settings. Anything needing + the internet stays out of the default set. +- Running several library instances in one process is not a test of several + system processes; do not describe it as one. + +## Before you open a change + +```bash +cargo fmt --all -- --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace --all-targets +``` + +CI runs these on Linux, macOS and Windows. A CI config existing is not evidence +that tests ran on every OS — say what you actually ran. + +Check the real API of the iroh version in `Cargo.lock` before using it. Do not +invent methods from memory. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8d19ad6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3877 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core", + "rustc_version", + "serde", + "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.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.6", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-assoc" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "iroh" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f8d1cfffc83efe39a1031aab423ce09cb8048071baba550508931e9a81ce46" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "rand", + "reqwest", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd1efd1aaecd68d4d1b0727a30c5811f43fb822843e48f65f6e5db3b7256cc9" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80276761b56e904e26ea71d0863beef0bb8d5ab6b639cbd1338e4b615209e3a1" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "iroh-base", + "n0-dns-resolver", + "n0-error", + "n0-future", + "portable-atomic", + "rand", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iroh-relay" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beb2294a9749d6a25fd7cd8bcf0fccd932f20716d4f967d85d3f23c7135ae6a2" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "n0-dns-resolver" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535c99c9a786bee714155d2ee4fc2477405c2c43536e1bc6a51de09a0d1448f7" +dependencies = [ + "derive_more", + "ipconfig", + "jni 0.22.4", + "lru", + "n0-error", + "n0-future", + "ndk-context", + "rand", + "reqwest", + "rustc-hash", + "rustls", + "simple-dns", + "system-configuration", + "tokio", + "tokio-rustls", + "tracing", + "webpki-roots", +] + +[[package]] +name = "n0-error" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c59ea174675ce6bc196878851e5049e11b8b1f9af3ba87e4d6df8d828bb001" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4412346410d6616f3668c40e53c298e5320c7b856f7a960e5914e442601be79f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "096c44b66a9b09b99b4dd36b1c295ff3a492039ccecaf55466bd6ddcdba59968" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core 0.8.2", + "netlink-packet-route 0.31.0", + "netlink-sys 0.8.8", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netdev" +version = "0.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c52c2584961c68f5a6e3356797344061f818c93b8acc9cd6d7e284795e563e" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core 0.9.0", + "netlink-packet-route 0.33.0", + "netlink-sys 0.9.0", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core 0.8.2", +] + +[[package]] +name = "netlink-packet-route" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core 0.9.0", + "zerocopy", +] + +[[package]] +name = "netlink-proto" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core 0.8.2", + "netlink-sys 0.8.8", + "thiserror 2.0.20", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netlink-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" +dependencies = [ + "bytes", + "libc", + "log", +] + +[[package]] +name = "netwatch" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39da9cad5f23aa43401f09497d3b280e51b5feba115d9ffbf38ca35d6ff95e96" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev 0.45.1", + "netdev 0.46.3", + "netlink-packet-core 0.8.2", + "netlink-packet-route 0.31.0", + "netlink-proto", + "netlink-sys 0.8.8", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "noq" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b78be567e796cfa74bb9bdc4117790af2d505eb887019cf8803244353eb09d89" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c1e5b6fe668491eca022f745a0a9402585626c73a7b839b3424ace15d6a9c8f" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc50afea61aff926e150a36c31f06094608848e114a3fe667d76ec233163b1c" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "papaya" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "serde", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6f884fa9a8d48101774bfbd3aeb81e968dd22cffd19a372da69f183db22c1a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501336eb7ba9e417300a6a0fa985721065467aa83a6dcf0422a8e43e4c0328fa" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tsunagi" +version = "0.1.0" +dependencies = [ + "data-encoding", + "directories", + "fs4", + "hex", + "hkdf", + "hmac", + "iroh", + "postcard", + "rand", + "rusqlite", + "serde", + "sha2", + "subtle", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tracing", + "tracing-subscriber", + "zeroize", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.20", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.20", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3e32342 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "tsunagi" +version = "0.1.0" +edition = "2024" +rust-version = "1.91" +license = "MIT OR Apache-2.0" +description = "Proof-of-concept library for small private mesh networks: persistent agent identity, deterministic network spaces, iroh-based control plane." +repository = "https://github.com/tsunagi-net/tsunagi" +readme = "README.md" +keywords = ["mesh", "p2p", "iroh", "networking"] +categories = ["network-programming"] + +[dependencies] +iroh = { version = "1.2", default-features = false, features = ["tls-ring"] } +tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } +rusqlite = { version = "0.40", features = ["bundled"] } +hkdf = "0.13" +hmac = "0.13" +sha2 = "0.11" +subtle = "2.6" +zeroize = { version = "1.9", features = ["derive"] } +rand = "0.10" +serde = { version = "1.0", features = ["derive"] } +postcard = { version = "1.1", default-features = false, features = ["use-std"] } +data-encoding = "2.11" +hex = "0.4" +thiserror = "2.0" +tracing = "0.1" +fs4 = { version = "1.1", features = ["sync"] } +directories = "6.0" + +[dev-dependencies] +tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] } +tempfile = "3.24" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[lints.rust] +missing_docs = "warn" +unsafe_code = "forbid" + +[lints.clippy] +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6883699 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# tsunagi + +A proof-of-concept Rust library for **small private mesh networks** — a handful +of friends, home machines, a few servers. Units to dozens of participants, not +thousands. + +The end user configures exactly two things: + +```text +network_name +secret # one shared secret; "password" and "secret" mean the same value +``` + +From those, every agent independently derives the same network space. There is +no central server, no network owner with special powers, no registration and no +majority vote. Anyone who knows the parameters can join; nobody has to trust +anybody else. + +## What this proof of concept actually does + +A working library with **real iroh connections** and integration tests: + +- persistent device identity stored in SQLite, stable across restarts; +- several independent networks at once in one agent; +- deterministic network identity derived from name + secret; +- candidates supplied by a replaceable discovery component; +- real iroh connections plus an explicit mutual proof of network membership; +- a small versioned control protocol: handshake, hostname/capability + announcement, ping/pong; +- automatic reconnect with bounded exponential backoff and jitter; +- status snapshots, an event stream and honest diagnostics; +- configuration restored after a restart; +- correct behaviour when the disposable cache is missing or corrupt. + +### What it deliberately does **not** do + +Not implemented, and not pretended to be: WireGuard or any other IP plugin, +Mainline DHT, DNS, routing through intermediate participants, a full CRDT, +dynamically loaded plugins, a system service, a complete CLI, or a local +control socket. Snapshot synchronisation and signed revocations are designed +for but not implemented — see [docs/sync-model.md](docs/sync-model.md). + +**Only control messages travel over iroh. User IP traffic is not tunnelled +through it.** Filtering user traffic is the operating system's and the user's +responsibility, not this library's. + +## Requirements + +- Rust 1.91 or newer (iroh 1.2 requires it) (edition 2024). Pinned dependencies in `Cargo.lock`. +- No internet, no DHT, no public relay, no administrator rights and no changes + to OS network settings are needed to build or test. + +## Checks + +```bash +cargo fmt --all -- --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace --all-targets +``` + +The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug` +to see agent logs while a test runs. + +There is also a runnable demo, which is a demo and not a substitute for the +tests: + +```bash +cargo run --example two_agents +``` + +## Usage + +```rust,no_run +use std::sync::Arc; +use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkName, NetworkSecret}; +use tsunagi::proto::ControlMessage; +use tsunagi::{Agent, Result}; + +// The library never starts a runtime, installs a logger, handles signals, +// forks, or calls process::exit. The binary owns all of that. +#[tokio::main] +async fn main() -> Result<()> { + let config = AgentConfig::new(StoragePaths::user_default()?) + .with_transport(TransportPolicy::N0Defaults) + .with_discovery(Arc::new(SharedMemoryDiscovery::new())); + + let agent = Agent::spawn(config).await?; + + let name = NetworkName::new("kitchen-table")?; + let secret = NetworkSecret::generate(); // 32 random bytes + println!("share this: {}", secret.encode().as_str()); + + let network = agent.join_network(&name, &secret).await?; + + let mut events = agent.subscribe(); + tokio::spawn(async move { + while let Ok(event) = events.recv().await { + println!("{event:?}"); + } + }); + + for peer in agent.network_status(network).await?.connected_peers() { + agent + .send(network, peer, ControlMessage::Ping { seq: 1, payload: vec![] }) + .await?; + } + + agent.shutdown().await; + Ok(()) +} +``` + +`TransportPolicy::LocalOnly` is the default, so a plain `AgentConfig::new` never +reaches the internet by accident. Opt into `DirectOnly` or `N0Defaults` +explicitly. + +## Storage + +Two physically separate SQLite files, placed wherever the library's +configuration says (`StoragePaths`). A future system service supplies its own +paths; tests always use temporary directories. + +| file | holds | when damaged | +|----------------|--------------------------------------------------|-------------------------| +| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset | +| `cache.sqlite` | address hints and other recoverable data | discarded and recreated | + +One state directory belongs to one live agent, enforced with a real OS file +lock rather than an existence check. + +## Documentation + +- [docs/architecture.md](docs/architecture.md) — module boundaries and runtime. +- [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake. +- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and + what is deliberately not built yet. +- [docs/threat-model.md](docs/threat-model.md) — threat model and known limits. +- [docs/testing.md](docs/testing.md) — what the suite covers and what it does not. +- [AGENTS.md](AGENTS.md) — rules for anyone (human or agent) changing this repo. + +## Security in one paragraph + +Membership is proved by an HMAC over a transcript keyed by a value derived from +the shared secret, bound to the specific iroh connection through the TLS +exporter, to the network id, to both endpoint identities and to distinct role +labels. This targets high-entropy secrets: there is no PAKE here, so a short +human passphrase is guessable offline by anyone who can reach the handshake. +Anyone who knows the secret is a full participant and can create many +identities. Read [docs/threat-model.md](docs/threat-model.md) before relying on +any of this. + +## Licence + +MIT OR Apache-2.0. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6f5f1c2 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,131 @@ +# Architecture + +Scope and non-scope are in [../README.md](../README.md). Rules for changing the +code are in [../AGENTS.md](../AGENTS.md). The wire format is in +[protocol.md](protocol.md). + +## Two independent planes + +**Control plane.** iroh provides connections between agents and carries control +messages. On top of it, this crate's protocol handles membership +authentication, participant announcements, capability exchange and — later — +state synchronisation and delivery of IP-plugin data. + +**Data plane.** Separate plugins create IP connectivity. WireGuard is the first +planned one; none exists yet. Plugin keys, configuration and lifecycle are +separate from iroh identity and from the network secret. The core moves an +opaque, bounded payload and never parses it. + +Only control messages travel over iroh. User IP traffic is not tunnelled +through it. + +A data plane failure never stops the daemon: the control plane keeps running +and the agent stays manageable. + +## Module responsibilities + +| component | responsibility | +|---|---| +| `identity` | persistent device identity; network space parameters; derived identifiers and keys | +| `discovery` | obtaining and publishing address hints — not authentication, not state transfer | +| `net` | iroh connections, addresses, paths, statistics, connection events | +| `proto` | message format, handshake, membership proof, protocol limits | +| `agent` | agent and per-network lifecycle, reconnect, in-process message routing | +| `storage` | mandatory state and the separately recoverable cache | +| `dataplane` | the minimal contract future IP plugins implement | + +Abstractions exist only where something is really substituted or really needs +isolating for tests: `NetworkDiscovery` and `IpPlugin`. Everything else is a +concrete type. + +## Runtime shape + +```text +Agent one persistent identity, one iroh endpoint, + ├── EndpointAdapter one state directory, N networks + ├── Storage (state.sqlite + cache.sqlite + ownership lock) + ├── accept loop task ── weak ref, exits when the agent is dropped + └── NetworkRuntime per NetworkId + ├── discovery + dial loop (bounded concurrency, backoff with jitter) + └── Session per peer + ├── reader task ── frames in -> SessionEvent + └── writer task ── encoded frames out +``` + +The library starts no runtime, installs no logging subscriber, handles no +signals, never forks and never calls `process::exit`. Startup +(`Agent::spawn`) and shutdown (`Agent::shutdown`) are explicit, background +tasks are joined on shutdown, and there is no global mutable state — several +independent agents run in one test process. + +`Agent::spawn` returns as soon as the local agent is ready. It never waits for +other participants or for a relay. + +### Concurrency decisions + +- The network runtime is a single task owning its own state, driven by a + command channel plus event channels. No shared locks on the hot path. +- A session is two tasks, because a partially consumed stream read cannot be + cancelled safely. The writer's frame write *is* cancellable, so shutdown + never waits on a peer that stopped reading. +- Outbound queues are bounded. A full queue fails that send instead of stalling + every other peer in the network. +- Envelopes are encoded in the runtime, not in the writer, so the exact number + of control bytes queued is known and reported rather than guessed. +- Simultaneous mutual dials are resolved by a deterministic rule both sides + compute identically: keep the session whose initiator has the smaller + endpoint id. + +### Storage + +Two physically separate SQLite files with their own schema versions and +migrations, no ORM. All access runs on the blocking pool; no database lock is +held across a network `await`. Files are owner-only where the platform supports +it. One state directory belongs to one live agent, enforced by an advisory OS +file lock — an existence check is not enough, because a crashed process must not +block a restart and two agents starting at once must not both win. A clean +shutdown releases the lock immediately. + +### Observability + +`AgentStatus` separates the three levels deliberately: + +- **endpoint** — bound sockets, observed addresses, cache health; +- **logical network** — dial attempts and failures, handshake failures, + sessions established, disconnects, control message and byte counts, protocol + violations, plugin errors; +- **connection** — verified paths, selected path, direct/relay, RTT, QUIC + counters. + +Values that cannot honestly be attributed to one network stay at the endpoint +level. A value iroh does not report is `None`. + +An iroh address is an address for iroh. It is not assumed usable by any other +protocol; a future WireGuard plugin gathers its own reachability data and ships +it through the control plane as an opaque announcement. + +### Relays + +Standard iroh behaviour, including relay fallback, is allowed for the control +plane via `TransportPolicy::N0Defaults`. Public relays are fine for development +and carry no availability guarantee. The default is `LocalOnly`, and the test +suite never depends on a relay. + +## Multiple networks + +Every session, message, task and status carries an explicit `NetworkId`. Being +authenticated in network A grants nothing in network B, even over a shared +physical connection; each network gets its own sessions. Deactivating one +network does not close the agent or disturb the others. + +Local deactivation is distinct from a future signed revocation of membership or +of a name. Nothing here is an irreversible global flag. + +## Planned, not built + +Signed per-author state, snapshots, revocations and merge rules are described +in [sync-model.md](sync-model.md). The module boundaries above are shaped so +that adding them does not require rewriting the core. The data plane locking +policy is likewise deliberately left coarse for now; the conflict domain is +described separately so it can later be narrowed to a network, peer, name or +plugin without touching the core. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..9664bbe --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,178 @@ +# Protocol + +Module boundaries are in [architecture.md](architecture.md); the threat model +is in [threat-model.md](threat-model.md). + +## Versioning + +Two versions exist and are independent: + +- **Identity scheme**, `tsunagi-network-id-v1`. Frozen. Changing it creates a + different network space for the same name and secret. +- **Control protocol**, ALPN `tsunagi/ctrl/1`, `PROTOCOL_VERSION = 1`. + +Upgrading the crate or bumping the control protocol must never change an +existing `NetworkId`. + +## Canonical encoding + +Everything that is hashed or MAC'd uses length-prefixed fields, written here as +`LP(x) = u32_be(len(x)) || x`. String concatenation is never used, so no two +different field splits can produce the same bytes. + +## Network space identity + +```text +salt = SHA-256( LP("tsunagi-network-id-v1") || LP(name_utf8) ) +prk = HKDF-SHA256-Extract(salt, ikm = secret_bytes) + +info(label) = LP("tsunagi-network-id-v1") || LP(label) +network_id = HKDF-Expand(prk, info("network-id"), 32) +discovery_key = HKDF-Expand(prk, info("discovery-key"), 32) +auth_key = HKDF-Expand(prk, info("handshake-auth"), 32) +``` + +HKDF's `info` parameter is what separates the three outputs (RFC 5869 §3.2). +The labels and this encoding are frozen. + +**Name rules.** 1..=64 bytes of UTF-8, no control characters, no leading or +trailing ASCII whitespace — such a name is *rejected*, not trimmed. The name is +used verbatim: no case folding, no Unicode normalisation. `Home` and `home` are +different network spaces. + +**Secret rules.** Used verbatim: never trimmed, case-folded, normalised or +truncated. At least 16 bytes; `NetworkSecret::generate()` produces 32 random +bytes. The canonical text form is `tsn1` followed by lowercase unpadded base32. + +**Key separation.** `network_id` is public. `discovery_key` is what a discovery +backend is told; it is secret-derived but is **not** a credential — learning it +does not help pass the handshake, and it must never be used as a password or +bearer token. `auth_key` never leaves the process. + +## No competing genesis + +A network's description is `NetworkDescriptor { scheme, name, network_id }`. It +contains no creator identity, no creation time and no owner signature, so two +agents started independently with the same parameters produce byte-identical +descriptors. No minimum-hash rule, no vote, no leader. The secret is not part +of it. + +A network's name does not change inside an existing space. A different name or +secret is a different space. + +## Framing + +One QUIC bidirectional stream per session. A frame is `u32_be(len) || payload`. +The announced length is checked against `Limits::max_frame_len` (64 KiB by +default) **before** any buffer of that size is allocated. Payloads are +[postcard], a compact deterministic serde format — not a general RPC framework. + +No encryption is layered on top of iroh: QUIC/TLS already provides +confidentiality, integrity and endpoint authentication. + +[postcard]: https://docs.rs/postcard + +## Handshake + +A successful iroh connection proves only *which endpoint* is on the other side, +because the endpoint id is the public key in the TLS certificate. Anyone can +dial us. Membership of a specific network is proved separately. + +iroh exposes the TLS exporter (RFC 5705) through +`Connection::export_keying_material`. That yields the same secret bytes on both +ends of *this* connection, which is what stops a proof being replayed +elsewhere. **It proves nothing about the shared secret on its own**, because +both ends of any connection can compute it. The membership proof is the HMAC +keyed by `auth_key`; the exporter output is just one of its inputs. + +```text +cb = TLS-Exporter(label = "tsunagi/handshake/v1", context = network_id, 32) + +transcript(role) = LP("tsunagi-handshake-v1") + || LP(role) // "initiator-proof" | "responder-proof" + || LP(u16_be(protocol_version)) + || LP(network_id) // 32 bytes + || LP(initiator_endpoint_id) // 32 bytes + || LP(responder_endpoint_id) // 32 bytes + || LP(cb) // 32 bytes + || LP(nonce_initiator) // 16 bytes + || LP(nonce_responder) // 16 bytes + +proof(role) = HMAC-SHA256(auth_key, transcript(role)) +``` + +What each input buys: + +| input | property | +|---|---| +| `auth_key` | membership in this network space | +| `cb` | binding to this connection; a captured proof is useless on another | +| `network_id` | binding to this network space | +| both endpoint ids | binding to these two identities | +| distinct `role` labels | no reflection: a proof cannot be bounced back | +| both nonces | freshness contributed by each side | + +Message order: + +```text +initiator -> responder : Hello { version, network_id, nonce_i } +initiator <- responder : HelloAck { version, nonce_r } +initiator -> responder : AuthProof { proof(initiator) } +initiator <- responder : AuthProof { proof(responder) } // only after the first verified +``` + +The responder emits nothing derived from `auth_key` until the initiator's proof +verifies, so a caller who does not know the secret learns nothing. Comparison +is constant time. Endpoint ids always come from the TLS certificate, never from +a peer's claim. Until both steps complete, no regular control message is +accepted in either direction. The whole exchange is bounded by +`Limits::handshake_timeout`. + +A `Hello` naming a network this agent does not have active is rejected with +"unknown network". Because the claim is unverified at that point, the rejection +event does not report a network id. + +## Control messages + +After authentication, every frame is an `Envelope { network_id, message }` and +the `network_id` is re-checked against the session's network on every message. +A mismatch ends that session and is counted as a protocol violation; it does +not affect other networks. + +| message | meaning | +|---|---| +| `Announce { hostname, capabilities }` | this agent's hostname and IP-plugin capabilities | +| `Ping { seq, payload }` | small request used to verify the exchange | +| `Pong { seq, payload }` | the echoed reply | +| `Bye { reason }` | graceful goodbye; not a revocation of anything | + +`PluginCapability { protocol, version, enabled, data }` is opaque to the core: +`data` is bounded and handed to the matching plugin unparsed. Nothing in it is +ever treated as a shell command, filesystem path or OS setting. + +Every decoded message is validated against `Limits` before it reaches anything +else. A rejected message never stops a network or the agent. + +## Limits + +Defaults from `Limits`, all configurable: + +| limit | default | +|---|---| +| frame payload | 64 KiB | +| hostname | 255 bytes | +| capabilities per announcement | 16 | +| capability payload | 4 KiB | +| echo payload | 4 KiB | +| reason string | 256 bytes | +| handshake timeout | 10 s | +| dial timeout | 10 s | +| write timeout | 30 s | +| concurrent dials per network | 8 | +| sessions per network | 64 | +| inbound handshakes in flight | 32 | +| outbound queue per session | 64 | + +Liveness of an established session is delegated to QUIC: iroh configures +keep-alives and an idle timeout, so a dead peer surfaces as a read error rather +than needing a heartbeat in this protocol. diff --git a/docs/sync-model.md b/docs/sync-model.md new file mode 100644 index 0000000..e040d2b --- /dev/null +++ b/docs/sync-model.md @@ -0,0 +1,86 @@ +# Planned state synchronisation + +**Nothing in this document is implemented.** The proof of concept exchanges +hostname and capability announcements over live sessions and keeps no +replicated history. This file records the intended direction so the module +boundaries in [architecture.md](architecture.md) stay compatible with it, and so +nobody mistakes the current announcements for synchronisation. + +There is no fake "ready CRDT" here and no snapshots that are not actually +verified. + +## The problem + +The network may be unstable. A participant can come back after months. So: + +- no dependence on the author of a change being online; +- no dependence on acknowledgements from every participant ever seen; +- any available replica holding the signed data must be able to hand it to a + returning participant without the original author present. + +## The model + +**Signed self-contained state per author, merged between replicas.** + +A record contains: the network, the author, the author's own retained version, +the full current content, and a signature. A change log may speed delivery up, +but recovery must never require the entire chain from the first event. + +A network snapshot is a set of verifiable authored records plus the revocations +needed to interpret them. It is **not** a SQLite dump, and **not** a single +document trusted merely because the neighbour who forwarded it signed it. + +## Merge rules + +- A snapshot is merged into local state, never substituted for it wholesale. +- An older version never rolls back a newer known one. +- Absence from a snapshot does not mean deletion. +- Duplicates do not change the result. +- Two conflicting signed records at the same version from the same author need + explicit handling; they are not resolved by luck. +- Neither arrival order nor system clocks decide a winner. +- Compaction must not drop what is needed to stop revoked records being + resurrected. + +## Hostnames + +A hostname is a mutable binding to a persistent author, not an identity. A +rename must be a signed record that revokes the specific old binding and +announces the new one, ideally atomically in one record. + +Turning a computer off is not a revocation of its hostname and does not remove +the participant. Revocations are not dropped merely because they are old, and +no acknowledgement from offline peers is required to keep working. + +## Storage requirement this creates + +When signed records land, writing the event and bumping the author's own +counter must happen in **one SQLite transaction, committed before the change is +published to the network**. SQLite gives atomic commit; use it instead of +separate, inconsistent writes. `state.sqlite` already has a schema version and +migrations for this. + +## Limits to state honestly + +- Data that every copy has lost is not recoverable from the secret. +- A signature proves authorship, not global freshness: a replica can be + behind, and you cannot tell from the signature alone. +- An isolated new client can end up with incomplete state and has no way to + know what it is missing. +- Anyone who knows the secret can author records, so a majority of records is + not evidence of anything. + +## Future tests + +These are **not implemented and must not be reported as passing**: + +- snapshot merge against the rules above, including conflicting same-version + records; +- revocation propagation and resistance to resurrection after compaction; +- long network partitions and rejoin after an extended absence; +- hostname rename with atomic revoke-and-announce; +- recovery of a returning participant from a replica that is not the author; +- NAT traversal and hole punching between real hosts; +- relay fallback behaviour against a self-hosted relay; +- multi-process and multi-host deployment, as opposed to several library + instances inside one test process. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..925968c --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,66 @@ +# Testing + +How to run everything is in [../README.md](../README.md). Rules for writing +tests are in [../AGENTS.md](../AGENTS.md). + +## Ground rules + +Tests use real iroh endpoints on loopback, real handshakes, real SQLite in +per-test temporary directories, and independent agent instances. Discovery is +substitutable; **iroh, authentication, message passing and persistent storage +are not**. + +The suite runs with no internet, no DHT, no public relay, no administrator +rights and no changes to OS network settings: endpoints bind `127.0.0.1:0` and +`[::1]:0`, relays are disabled, address lookup is cleared, port mapping is +disabled, and net-report probing is reduced to its minimum. + +Synchronisation is always "wait for a specific event or condition under one +overall deadline" (`wait_event`, `wait_until`, 30 s). `settle()` exists only +for asserting that something did *not* happen. Ports are dynamic and +directories are isolated, so tests run in parallel. + +Several library instances in one process is exactly that. It is **not** a test +of several system processes, and is not presented as one. + +## What is covered + +| # | scenario | file | +|---|---|---| +| 1 | deterministic identity: same name + secret ⇒ same space on different devices; a changed name or secret changes it; hostname, device key and restart do not | `tests/identity.rs` | +| 2 | four agents find each other, authenticate for real and exchange distinguishable messages; a late joiner is picked up; opaque plugin capabilities cross the control plane | `tests/multi_peer.rs` | +| 3 | an attacker who knows the address and the correct public `NetworkId` but not the secret is rejected at the handshake | `tests/authentication.rs` | +| 4 | one agent in two networks: statuses and messages do not mix; a session authenticated for one network cannot speak for the other; deactivating one leaves the other running | `tests/network_isolation.rs` | +| 5 | full stop and recreation from the same database: identity and settings survive, sessions come back automatically, a new local UDP port does not break recovery | `tests/restart.rs` | +| 6 | changing the secret through the library API: device identity survives, old sessions and messages get no access to the new space, and the retired space stays retired across a restart | `tests/restart.rs` | +| 7 | missing, corrupt and stale cache do not prevent connecting; a corrupt mandatory store is a clear error and never a fresh identity; a newer schema is refused; secrets stay out of status and `Debug` | `tests/cache_and_state.rs` | +| 8 | a dead candidate and a vanished peer do not block the others; retries are bounded and stop when the network is deactivated | `tests/resilience.rs` | +| 9 | wrong version, a message before authentication, a proof replayed on another connection, an oversized frame and a `Hello` for an inactive network are all rejected without taking the agent down | `tests/authentication.rs` | +| 10 | a second agent on the same state directory gets a clear error; after a clean stop the directory reopens; shutdown ends background tasks and refuses further work; independent agents coexist in one process | `tests/resilience.rs` | + +`tests/discovery.rs` covers the discovery contract itself: a static bootstrap +candidate is enough to join, several backends compose, entries are withdrawn +when a network stops, and a forgotten network stays forgotten across a restart. + +Unit tests in `src/proto/handshake.rs` cover the transcript construction +itself: role separation, channel binding, identity and network binding, +unambiguous encoding, and rejection under the wrong key. + +`tests/end_to_end.rs` is the vertical slice: persistent identity → network +space → discovery → iroh → authentication → message exchange. + +## Not covered, and not claimed to be + +Listed in [sync-model.md](sync-model.md#future-tests): snapshots, revocations, +long partitions, hostname renames, recovery of a returning participant, NAT +traversal, relay fallback, and multi-process or multi-host deployment. None of +these are implemented, and none are marked as passing. + +## Debugging a test + +```bash +TSUNAGI_TEST_LOG=tsunagi=debug cargo test --test multi_peer -- --nocapture +``` + +The library never installs a global subscriber; the harness opts in only when +that variable is set. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..e5c0469 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,79 @@ +# Threat model and known limits + +Read this before relying on anything here. The protocol is in +[protocol.md](protocol.md). + +## What is protected + +- **Network membership.** A peer must prove knowledge of `auth_key`, derived + from the network name and shared secret, to get an authenticated session. + Knowing the public `NetworkId`, or an agent's address, is not enough. +- **Endpoint authenticity.** iroh's QUIC/TLS handshake authenticates the + remote endpoint id, which is its public key. Identities in the membership + transcript are taken from the certificate, never from a peer's claim. +- **Connection binding.** The membership proof includes TLS exporter output, so + a proof captured on one connection does not verify on another. +- **Role separation.** Initiator and responder proofs cover different + transcripts, so a proof cannot be reflected back at its sender. +- **Network isolation.** A session authenticated for network A cannot carry + messages for network B, even over a shared physical connection. +- **Confidentiality and integrity in transit.** Provided by QUIC/TLS. This + crate adds no encryption of its own. +- **Resource bounds.** Frame lengths are validated before allocation; strings, + lists, queues, concurrent dials and in-flight handshakes are all bounded; + handshakes, dials and writes have timeouts. + +## What is not protected + +- **Anyone who knows the secret is a full participant.** They can create + arbitrarily many identities, flood the network with records and collide with + other participants' names. This is why a majority is not a root of trust. + Signatures protect authorship; they do not make a participant honest. +- **Weak secrets.** This targets high-entropy secrets. There is no PAKE, so a + short human passphrase can be guessed offline by anyone who can reach the + handshake. Use `NetworkSecret::generate()`. +- **Addresses and metadata are observable.** Anyone able to watch the network + sees addresses, timing and volume. Discovery backends see the + `discovery_key` and the addresses published under it, which is enough to map + a network's participants. This library does not make a network anonymous, and + having iroh under it does not make it so. +- **A cloned state directory is a cloned identity.** `state.sqlite` holds the + device secret key and the network secrets. Copying it copies the participant. + Restoring an old backup rolls the agent's state back, which — once signed + records exist — can resurrect revoked information or replay stale versions. +- **A compromised host.** The secret is on disk to survive restarts. File + permissions are owner-only where the platform supports it, and the state + directory takes an ownership lock, but neither defends against a user who can + read the file or against malware running as that user. +- **User IP traffic.** Not carried here at all. Filtering it is the operating + system's and the user's job. +- **Denial of service.** Bounds and timeouts stop trivial resource exhaustion + from a single peer. They do not make the agent resistant to a determined + attacker who knows the secret, and no rate limiting per identity exists yet. +- **Global freshness.** A signature proves authorship, not that you have the + newest state. See [sync-model.md](sync-model.md). +- **Discovery is not trustworthy.** It returns candidates. A hostile or stale + discovery backend can waste dial attempts and learn addresses; it cannot + forge membership. + +## Deliberate design consequences + +- **No owner, no vote.** Nobody can evict anybody. Removing a participant means + changing the secret, which creates a different network space that the removed + participant cannot enter. +- **Rotating the secret is not revocation of past access.** Anyone who held the + old secret keeps whatever they already saw. +- **Local deactivation is not revocation.** Deactivating a network stops this + agent participating. It says nothing about anyone else. +- **Failures are contained, not escalated.** A bad proof, wrong secret, + malformed frame or unknown version rejects one message or one session. It + never stops another network and never stops the agent, and there is no + irreversible global error flag. + +## Cryptographic choices + +Standard primitives only, no home-made constructions: HKDF-SHA256 (RFC 5869) +for key separation, HMAC-SHA256 for the membership proof, constant-time +verification, iroh's Ed25519 endpoint keys and QUIC/TLS for the transport, and +RFC 5705 TLS exporter output for channel binding. There is no custom encryption +layer and no custom PAKE. diff --git a/examples/two_agents.rs b/examples/two_agents.rs new file mode 100644 index 0000000..fa2672d --- /dev/null +++ b/examples/two_agents.rs @@ -0,0 +1,148 @@ +//! A tiny runnable demonstration of the library. +//! +//! Run it with: +//! +//! ```text +//! cargo run --example two_agents +//! ``` +//! +//! It starts two agents in one process, on loopback only, joins them to the +//! same network space and exchanges one request/response. It is a demo, not a +//! substitute for the integration tests in `tests/`. + +use std::sync::Arc; +use std::time::Duration; + +use tsunagi::agent::Event; +use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkName, NetworkSecret}; +use tsunagi::proto::ControlMessage; +use tsunagi::{Agent, Result}; + +fn config(root: &std::path::Path, discovery: &SharedMemoryDiscovery) -> AgentConfig { + AgentConfig::new(StoragePaths::under(root)) + // Loopback only: no relays, no address lookup, no port mapping. + .with_transport(TransportPolicy::LocalOnly) + .with_loopback_bind() + .with_discovery(Arc::new(discovery.clone())) + .with_discovery_interval(Duration::from_millis(200)) +} + +// The library never starts a runtime of its own; the binary owns it. +#[tokio::main] +async fn main() -> Result<()> { + // The library never installs a global subscriber either. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .init(); + + let root = match tempfile::TempDir::new() { + Ok(root) => root, + Err(err) => { + eprintln!("cannot create a temporary directory: {err}"); + return Ok(()); + } + }; + let discovery = SharedMemoryDiscovery::new(); + + let alice = + Agent::spawn(config(&root.path().join("alice"), &discovery).with_hostname("alice")).await?; + let bob = + Agent::spawn(config(&root.path().join("bob"), &discovery).with_hostname("bob")).await?; + + // The end user supplies exactly these two values. + let name = NetworkName::new("kitchen-table")?; + let secret = NetworkSecret::generate(); + println!( + "network secret (keep it safe): {}", + secret.encode().as_str() + ); + + let network = alice.join_network(&name, &secret).await?; + let same = bob.join_network(&name, &secret).await?; + // The same name and secret always derive the same network space. + if network != same { + eprintln!("network derivation is not deterministic; this is a bug"); + return Ok(()); + } + println!("network id: {network}"); + println!("alice: {}", alice.endpoint_id()); + println!("bob: {}", bob.endpoint_id()); + + let mut events = alice.subscribe(); + loop { + match events.recv().await { + Ok(Event::PeerConnected { + peer, + role, + transport, + rtt, + .. + }) => { + println!("alice authenticated {peer} as {role:?} over {transport:?} rtt={rtt:?}"); + break; + } + Ok(_) => {} + Err(err) => { + eprintln!("event stream ended: {err}"); + break; + } + } + } + + alice + .send( + network, + bob.endpoint_id(), + ControlMessage::Ping { + seq: 1, + payload: b"hello".to_vec(), + }, + ) + .await?; + + while let Ok(event) = events.recv().await { + if let Event::MessageReceived { + peer, + message: ControlMessage::Pong { seq, payload }, + .. + } = event + { + println!( + "pong from {peer}: seq={seq} payload={:?}", + String::from_utf8_lossy(&payload) + ); + break; + } + } + + let status = alice.status().await?; + println!("\nalice status:"); + println!(" hostname {}", status.hostname); + println!(" bound sockets {:?}", status.bound_sockets); + println!(" cache {:?}", status.cache_outcome); + for net in &status.networks { + println!(" network {} ({:?})", net.name, net.state); + for peer in &net.peers { + println!( + " peer {} hostname={:?} transport={:?} rtt={:?}", + peer.endpoint_id, peer.hostname, peer.transport, peer.rtt + ); + for path in &peer.paths { + println!( + " path {:?} selected={} rtt={:?}", + path.remote, path.is_selected, path.rtt + ); + } + } + println!(" metrics {:?}", net.metrics); + } + + alice.shutdown().await; + bob.shutdown().await; + Ok(()) +} diff --git a/src/agent/events.rs b/src/agent/events.rs new file mode 100644 index 0000000..9137ec9 --- /dev/null +++ b/src/agent/events.rs @@ -0,0 +1,112 @@ +//! Events published by a running agent. +//! +//! Events are delivered through a bounded [`tokio::sync::broadcast`] channel. +//! A slow subscriber is lagged, never allowed to stall the runtime. +//! +//! Nothing here ever carries a secret, a derived key or a handshake proof. + +use std::time::Duration; + +use iroh::EndpointId; + +use crate::identity::NetworkId; +use crate::net::TransportKind; +use crate::proto::ControlMessage; +use crate::proto::handshake::Role; + +/// Something that happened inside the agent. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Event { + /// A network was activated locally. + NetworkActivated { + /// The network. + network: NetworkId, + }, + /// A network was deactivated locally. + /// + /// This is a local deactivation only. It is not a signed revocation of + /// membership, and it says nothing about the network's other participants. + NetworkDeactivated { + /// The network. + network: NetworkId, + }, + /// A peer completed the handshake and has an authenticated session. + PeerConnected { + /// The network the session belongs to. + network: NetworkId, + /// Authenticated peer endpoint id. + peer: EndpointId, + /// Which side this agent played. + role: Role, + /// How the connection currently reaches the peer. + transport: TransportKind, + /// RTT of the selected path, if iroh reported one. + rtt: Option, + }, + /// A peer's session ended. + PeerDisconnected { + /// The network the session belonged to. + network: NetworkId, + /// The peer. + peer: EndpointId, + /// Why the session ended. Free of secrets. + reason: String, + }, + /// A control message arrived on an authenticated session. + MessageReceived { + /// The network. + network: NetworkId, + /// The peer that sent it. + peer: EndpointId, + /// The message. + message: ControlMessage, + }, + /// An outbound dial failed. + /// + /// A dead candidate produces these and nothing else; other peers keep + /// connecting normally. + DialFailed { + /// The network. + network: NetworkId, + /// The candidate that could not be reached. + peer: EndpointId, + /// Why it failed. + reason: String, + }, + /// A handshake was rejected. + /// + /// The network is `None` when the failure happened before the peer's + /// requested network could be resolved. + HandshakeRejected { + /// The network, when known. + network: Option, + /// The peer, when known. + peer: Option, + /// Why it was rejected. + reason: String, + }, + /// A message or session was rejected for violating the protocol. + ProtocolViolation { + /// The network, when known. + network: Option, + /// The peer, when known. + peer: Option, + /// What was wrong. + reason: String, + }, + /// The disposable cache was discarded and recreated at startup. + CacheReset { + /// Why it was discarded. Free of secrets. + reason: String, + }, + /// An IP plugin reported an error. Never fatal. + PluginError { + /// The network the call was scoped to. + network: NetworkId, + /// Plugin protocol id. + protocol: String, + /// The reported error. + reason: String, + }, +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..820959b --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,611 @@ +//! The agent runtime. +//! +//! An [`Agent`] owns one persistent identity, one iroh endpoint, one state +//! directory and any number of networks. It is started explicitly with +//! [`Agent::spawn`] and stopped explicitly with [`Agent::shutdown`]; it starts +//! no runtime of its own, installs no global logger, handles no signals and +//! never calls `process::exit`. Several agents can therefore run side by side in +//! one process, which is exactly what the integration tests do. +//! +//! # Local readiness +//! +//! [`Agent::spawn`] returns as soon as the local agent is ready. It never waits +//! for other participants to appear or for a relay to become reachable. +//! +//! # Failure containment +//! +//! A bad signature, a wrong secret, a malformed frame or an unknown version +//! rejects that message or that session. It never stops another network and +//! never stops the agent. There is no global, irreversible error flag. + +mod events; +mod network; +mod session; +mod shutdown; +mod status; + +pub use events::Event; +pub use status::{ + AgentStatus, CandidateStatus, NetworkMetrics, NetworkState, NetworkStatus, PeerStatus, +}; + +use std::collections::HashMap; +use std::sync::{Arc, Weak}; + +use iroh::{EndpointAddr, EndpointId}; +use tokio::sync::{RwLock, broadcast, mpsc, oneshot}; +use tokio::task::JoinHandle; + +use crate::config::{AgentConfig, Limits}; +use crate::error::{Error, Result}; +use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret}; +use crate::net::EndpointAdapter; +use crate::proto::handshake; +use crate::proto::message::ControlMessage; +use crate::storage::{CacheOutcome, Storage}; + +use network::{InboundSession, NetCommand, NetworkHandle, RuntimeParams}; +use shutdown::Shutdown; + +/// Summary of a configured network, whether or not it is running. +#[derive(Debug, Clone)] +pub struct ConfiguredNetwork { + /// Public network identifier. + pub network_id: NetworkId, + /// Network name. + pub name: NetworkName, + /// Whether it is activated automatically at startup. + pub auto_start: bool, + /// Whether it is currently running. + pub active: bool, +} + +/// A running agent. +/// +/// Cloning gives another handle to the same agent. +#[derive(Debug, Clone)] +pub struct Agent { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + config: AgentConfig, + limits: Arc, + storage: Storage, + identity: DeviceIdentity, + adapter: EndpointAdapter, + hostname: String, + events: broadcast::Sender, + networks: RwLock>, + shutdown: Shutdown, + accept_task: std::sync::Mutex>>, +} + +impl Drop for Inner { + fn drop(&mut self) { + // Nothing here awaits; this is only a safety net for a handle that was + // dropped without an explicit shutdown. + self.shutdown.trigger(); + if let Ok(mut guard) = self.accept_task.lock() + && let Some(task) = guard.take() + { + task.abort(); + } + } +} + +impl Agent { + /// Starts an agent. + /// + /// Opens the state store (taking its ownership lock), restores the + /// persistent device identity, binds the iroh endpoint and activates every + /// configured network whose auto-start flag is set. + pub async fn spawn(config: AgentConfig) -> Result { + let storage = Storage::open(&config.paths)?; + let identity = storage.device_identity().await?; + let adapter = EndpointAdapter::bind(&config, &identity).await?; + + let hostname = resolve_hostname(&config, &storage, identity.endpoint_id())?; + storage.set_hostname(hostname.clone()).await?; + + let (events, _) = broadcast::channel(config.limits.event_buffer); + let limits = Arc::new(config.limits.clone()); + + let inner = Arc::new(Inner { + limits, + storage, + identity, + adapter, + hostname, + events, + networks: RwLock::new(HashMap::new()), + shutdown: Shutdown::new(), + accept_task: std::sync::Mutex::new(None), + config, + }); + + if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() { + let _ = inner.events.send(Event::CacheReset { reason }); + } + + let accept = tokio::spawn(accept_loop(Arc::downgrade(&inner))); + if let Ok(mut guard) = inner.accept_task.lock() { + *guard = Some(accept); + } + + let agent = Self { inner }; + + for stored in agent.inner.storage.list_networks().await? { + if stored.auto_start { + let keys = NetworkKeys::derive(&stored.name, &stored.secret); + agent.activate_with_keys(keys).await?; + } + } + + Ok(agent) + } + + /// This device's persistent endpoint id. + pub fn endpoint_id(&self) -> EndpointId { + self.inner.identity.endpoint_id() + } + + /// This endpoint's dialable address as iroh currently reports it. + pub fn endpoint_addr(&self) -> EndpointAddr { + self.inner.adapter.addr() + } + + /// An address containing only the locally bound sockets. + /// + /// Handy when relays and address lookup are disabled and peers must be + /// handed literal addresses, as in the test suite. + pub fn local_addr(&self) -> EndpointAddr { + self.inner.adapter.loopback_addr() + } + + /// The hostname announced to peers. + pub fn hostname(&self) -> &str { + &self.inner.hostname + } + + /// The underlying iroh endpoint, for callers that need more detail. + pub fn endpoint(&self) -> &iroh::Endpoint { + self.inner.adapter.endpoint() + } + + /// Subscribes to agent events. + /// + /// The channel is bounded; a subscriber that falls behind is lagged rather + /// than allowed to stall the runtime. + pub fn subscribe(&self) -> broadcast::Receiver { + self.inner.events.subscribe() + } + + /// Adds a network to the persistent configuration and activates it. + /// + /// The same `(name, secret)` always produces the same [`NetworkId`], on + /// every device. + pub async fn join_network( + &self, + name: &NetworkName, + secret: &NetworkSecret, + ) -> Result { + let keys = NetworkKeys::derive(name, secret); + let network_id = keys.network_id(); + self.inner + .storage + .upsert_network(network_id, name.clone(), secret.clone(), true) + .await?; + self.activate_with_keys(keys).await?; + Ok(network_id) + } + + /// Activates a configured network that is currently inactive. + pub async fn activate_network(&self, network_id: NetworkId) -> Result<()> { + let stored = self + .inner + .storage + .list_networks() + .await? + .into_iter() + .find(|stored| stored.network_id == network_id) + .ok_or(Error::NetworkUnknown(network_id))?; + let keys = NetworkKeys::derive(&stored.name, &stored.secret); + self.inner.storage.set_auto_start(network_id, true).await?; + self.activate_with_keys(keys).await + } + + async fn activate_with_keys(&self, keys: NetworkKeys) -> Result<()> { + if self.inner.shutdown.is_triggered() { + return Err(Error::Stopped); + } + let network_id = keys.network_id(); + let mut networks = self.inner.networks.write().await; + if networks.contains_key(&network_id) { + return Err(Error::NetworkAlreadyActive(network_id)); + } + + let handle = network::spawn(RuntimeParams { + keys, + adapter: self.inner.adapter.clone(), + storage: self.inner.storage.clone(), + events: self.inner.events.clone(), + limits: Arc::clone(&self.inner.limits), + reconnect: self.inner.config.reconnect.clone(), + discovery: self.inner.config.discovery.clone(), + discovery_interval: self.inner.config.discovery_interval, + plugins: self.inner.config.plugins.clone(), + hostname: self.inner.hostname.clone(), + }); + networks.insert(network_id, handle); + drop(networks); + + let _ = self.inner.events.send(Event::NetworkActivated { + network: network_id, + }); + Ok(()) + } + + /// Deactivates a running network, leaving its configuration in place. + /// + /// This is a local action. It is not a signed revocation of membership and + /// it does not remove this agent from anyone else's view of the network. + /// Other networks keep running. + pub async fn deactivate_network(&self, network_id: NetworkId) -> Result<()> { + let handle = { + let mut networks = self.inner.networks.write().await; + networks + .remove(&network_id) + .ok_or(Error::NetworkNotActive(network_id))? + }; + handle.stop().await; + for plugin in &self.inner.config.plugins { + plugin.on_network_deactivated(network_id); + } + self.inner.storage.set_auto_start(network_id, false).await?; + Ok(()) + } + + /// Deactivates a network if it is running and removes it from the state + /// store together with its cached hints. + pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> { + if self.is_active(network_id).await { + self.deactivate_network(network_id).await?; + } + self.inner.storage.remove_network(network_id).await + } + + /// Whether a network is currently running. + pub async fn is_active(&self, network_id: NetworkId) -> bool { + self.inner.networks.read().await.contains_key(&network_id) + } + + /// Lists configured networks and whether each is running. + pub async fn list_networks(&self) -> Result> { + let active: Vec = self.inner.networks.read().await.keys().copied().collect(); + Ok(self + .inner + .storage + .list_networks() + .await? + .into_iter() + .map(|stored| ConfiguredNetwork { + network_id: stored.network_id, + name: stored.name, + auto_start: stored.auto_start, + active: active.contains(&stored.network_id), + }) + .collect()) + } + + /// Sends a control message to one authenticated peer in one network. + /// + /// Fails if that network is not active or if there is no authenticated + /// session with that peer *in that network*. Being authenticated in network + /// A never grants the right to send into network B. + pub async fn send( + &self, + network_id: NetworkId, + peer: EndpointId, + message: ControlMessage, + ) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.command( + network_id, + NetCommand::Send { + peer, + message, + reply: reply_tx, + }, + ) + .await?; + reply_rx.await.map_err(|_| Error::Stopped)? + } + + /// Sends a control message to every authenticated peer in a network. + /// + /// Returns how many sessions accepted it into their outbound queue. + pub async fn broadcast(&self, network_id: NetworkId, message: ControlMessage) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.command( + network_id, + NetCommand::Broadcast { + message, + reply: reply_tx, + }, + ) + .await?; + reply_rx.await.map_err(|_| Error::Stopped) + } + + /// Status of one running network. + pub async fn network_status(&self, network_id: NetworkId) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.command(network_id, NetCommand::Status { reply: reply_tx }) + .await?; + reply_rx + .await + .map(|boxed| *boxed) + .map_err(|_| Error::Stopped) + } + + /// Status of the whole agent, including configured but inactive networks. + pub async fn status(&self) -> Result { + let endpoint = self.inner.adapter.snapshot(); + let active: Vec = self.inner.networks.read().await.keys().copied().collect(); + + let mut networks = Vec::new(); + for stored in self.inner.storage.list_networks().await? { + if active.contains(&stored.network_id) + && let Ok(status) = self.network_status(stored.network_id).await + { + networks.push(status); + continue; + } + let keys = NetworkKeys::derive(&stored.name, &stored.secret); + networks.push(NetworkStatus { + descriptor: keys.descriptor(), + name: stored.name, + network_id: stored.network_id, + state: NetworkState::Inactive, + peers: Vec::new(), + candidates: Vec::new(), + metrics: NetworkMetrics::default(), + }); + } + + Ok(AgentStatus { + endpoint_id: endpoint.endpoint_id, + hostname: self.inner.hostname.clone(), + bound_sockets: endpoint.bound_sockets, + observed_addrs: endpoint.observed_addrs, + endpoint_addr: self.inner.adapter.addr(), + cache_outcome: self.inner.storage.cache_outcome().clone(), + cache_healthy: self.inner.storage.cache_healthy(), + networks, + }) + } + + /// Asks one network to re-run discovery and re-evaluate dials right now. + /// + /// Call this when the host's network environment changed. Platform wake-up + /// notifications can be wired to it later. + pub async fn recheck_network(&self, network_id: NetworkId) -> Result<()> { + self.command(network_id, NetCommand::Recheck).await + } + + /// Asks every running network to re-run discovery right now. + pub async fn recheck(&self) { + let senders: Vec> = self + .inner + .networks + .read() + .await + .values() + .map(|handle| handle.commands.clone()) + .collect(); + for sender in senders { + let _ = sender.send(NetCommand::Recheck).await; + } + } + + /// Stops every network, the accept loop and the endpoint. + /// + /// After this returns, the state directory can be opened by another agent. + pub async fn shutdown(&self) { + self.inner.shutdown.trigger(); + + let handles: Vec = { + let mut networks = self.inner.networks.write().await; + networks.drain().map(|(_, handle)| handle).collect() + }; + for handle in handles { + handle.stop().await; + } + + self.inner.adapter.close().await; + + let task = self + .inner + .accept_task + .lock() + .ok() + .and_then(|mut guard| guard.take()); + if let Some(task) = task { + let _ = task.await; + } + + // Release the directory so another instance can claim it right away. + self.inner.storage.release_ownership_lock(); + } + + async fn command(&self, network_id: NetworkId, command: NetCommand) -> Result<()> { + let sender = { + let networks = self.inner.networks.read().await; + networks + .get(&network_id) + .map(|handle| handle.commands.clone()) + .ok_or(Error::NetworkNotActive(network_id))? + }; + sender.send(command).await.map_err(|_| Error::Stopped) + } +} + +/// Accepts inbound connections and routes authenticated sessions to networks. +/// +/// Holds only a weak reference, so dropping every [`Agent`] handle lets the +/// runtime state be released and this loop exit. +async fn accept_loop(weak: Weak) { + let Some(inner) = weak.upgrade() else { + return; + }; + let endpoint = inner.adapter.endpoint().clone(); + let shutdown = inner.shutdown.clone(); + let permits = Arc::new(tokio::sync::Semaphore::new( + inner.limits.max_inbound_handshakes, + )); + drop(inner); + + loop { + let incoming = tokio::select! { + biased; + _ = shutdown.wait() => break, + incoming = endpoint.accept() => match incoming { + Some(incoming) => incoming, + None => break, + }, + }; + + let Some(inner) = weak.upgrade() else { + break; + }; + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + // Too many handshakes in flight: refuse cheaply instead of queueing. + incoming.refuse(); + continue; + }; + + tokio::spawn(async move { + let _permit = permit; + handle_incoming(inner, incoming).await; + }); + } +} + +async fn handle_incoming(inner: Arc, incoming: iroh::endpoint::Incoming) { + let connecting = match incoming.accept() { + Ok(connecting) => connecting, + Err(err) => { + tracing::debug!(%err, "inbound connection could not be accepted"); + return; + } + }; + let conn = match connecting.await { + Ok(conn) => conn, + Err(err) => { + tracing::debug!(%err, "inbound connection failed during setup"); + return; + } + }; + let peer = conn.remote_id(); + + let (mut send, mut recv) = match conn.accept_bi().await { + Ok(streams) => streams, + Err(err) => { + tracing::debug!(%err, "peer did not open a control stream"); + return; + } + }; + + // Snapshot the active networks so the handshake's lookup stays synchronous. + let known: HashMap = inner + .networks + .read() + .await + .iter() + .map(|(id, handle)| (*id, handle.keys.clone())) + .collect(); + + let local_id = inner.identity.endpoint_id(); + let outcome = handshake::respond( + &conn, + &mut send, + &mut recv, + local_id, + &inner.limits, + |network_id| known.get(&network_id).cloned(), + ) + .await; + + let outcome = match outcome { + Ok(outcome) => outcome, + Err(err) => { + // The network id is deliberately not reported here: before a + // successful handshake the peer's claim is unverified. + let network = None; + conn.close(2u32.into(), b"handshake rejected"); + let _ = inner.events.send(Event::HandshakeRejected { + network, + peer: Some(peer), + reason: err.to_string(), + }); + return; + } + }; + + let sender = { + let networks = inner.networks.read().await; + networks + .get(&outcome.network_id) + .map(|handle| handle.commands.clone()) + }; + let Some(sender) = sender else { + // The network was deactivated while the handshake ran. + conn.close(3u32.into(), b"network no longer active"); + return; + }; + + let inbound = InboundSession { + conn, + send, + recv, + outcome, + }; + if sender + .send(NetCommand::Inbound(Box::new(inbound))) + .await + .is_err() + { + tracing::debug!("network runtime stopped before the session could be installed"); + } +} + +/// Picks the hostname to announce. +/// +/// Order: explicit configuration, then what the state store already holds, then +/// a best-effort environment variable, then a stable fallback derived from the +/// endpoint id. The library does not shell out to discover a hostname. +fn resolve_hostname( + config: &AgentConfig, + storage: &Storage, + endpoint_id: EndpointId, +) -> Result { + if let Some(hostname) = &config.hostname { + return Ok(hostname.clone()); + } + if let Some(stored) = storage.hostname_blocking()? + && !stored.is_empty() + { + return Ok(stored); + } + for key in ["HOSTNAME", "COMPUTERNAME"] { + if let Ok(value) = std::env::var(key) { + let value = value.trim(); + if !value.is_empty() { + return Ok(value.to_string()); + } + } + } + Ok(format!("tsunagi-{}", endpoint_id.fmt_short())) +} diff --git a/src/agent/network.rs b/src/agent/network.rs new file mode 100644 index 0000000..c140d09 --- /dev/null +++ b/src/agent/network.rs @@ -0,0 +1,856 @@ +//! The per-network runtime. +//! +//! One of these runs for every locally active network. It owns that network's +//! sessions, its dial loop and its counters. Everything it touches carries an +//! explicit [`NetworkId`], so deactivating or breaking one network cannot +//! disturb another and cannot stop the agent. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use iroh::EndpointId; +use iroh::endpoint::{Connection, RecvStream, SendStream}; +use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio::task::JoinHandle; + +use crate::config::{Limits, ReconnectPolicy}; +use crate::dataplane::{PluginCapability, SharedPlugin}; +use crate::discovery::{Candidate, CandidateSource, NetworkDiscovery}; +use crate::error::{Error, Result}; +use crate::identity::{NetworkId, NetworkKeys}; +use crate::net::{EndpointAdapter, PathAddr, snapshot_connection}; +use crate::proto::handshake::{self, HandshakeOutcome, Role}; +use crate::proto::message::{Announcement, ControlMessage, Envelope, encode, kind}; +use crate::storage::Storage; + +use super::events::Event; +use super::session::{self, Session, SessionEvent}; +use super::shutdown::Shutdown; +use super::status::{CandidateStatus, NetworkMetrics, NetworkState, NetworkStatus, PeerStatus}; + +/// An inbound connection that already passed the handshake. +#[derive(Debug)] +pub(crate) struct InboundSession { + pub(crate) conn: Connection, + pub(crate) send: SendStream, + pub(crate) recv: RecvStream, + pub(crate) outcome: HandshakeOutcome, +} + +/// Commands accepted by a network runtime. +pub(crate) enum NetCommand { + Inbound(Box), + Send { + peer: EndpointId, + message: ControlMessage, + reply: oneshot::Sender>, + }, + Broadcast { + message: ControlMessage, + reply: oneshot::Sender, + }, + Status { + reply: oneshot::Sender>, + }, + Recheck, +} + +impl std::fmt::Debug for NetCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NetCommand::Inbound(_) => f.write_str("Inbound"), + NetCommand::Send { peer, message, .. } => { + write!(f, "Send({}, {})", peer.fmt_short(), kind(message)) + } + NetCommand::Broadcast { message, .. } => write!(f, "Broadcast({})", kind(message)), + NetCommand::Status { .. } => f.write_str("Status"), + NetCommand::Recheck => f.write_str("Recheck"), + } + } +} + +/// Handle to a running network runtime. +#[derive(Debug)] +pub(crate) struct NetworkHandle { + pub(crate) keys: NetworkKeys, + pub(crate) commands: mpsc::Sender, + shutdown: Shutdown, + task: JoinHandle<()>, +} + +impl NetworkHandle { + /// Stops the runtime and waits for its task to finish. + pub(crate) async fn stop(self) { + self.shutdown.trigger(); + let _ = self.task.await; + } +} + +/// Everything a network runtime needs to run. +pub(crate) struct RuntimeParams { + pub(crate) keys: NetworkKeys, + pub(crate) adapter: EndpointAdapter, + pub(crate) storage: Storage, + pub(crate) events: broadcast::Sender, + pub(crate) limits: Arc, + pub(crate) reconnect: ReconnectPolicy, + pub(crate) discovery: Option>, + pub(crate) discovery_interval: Duration, + pub(crate) plugins: Vec, + pub(crate) hostname: String, +} + +/// Outcome of one outbound dial. +enum DialOutcome { + Established(Box), + Failed { + peer: EndpointId, + reason: String, + during_handshake: bool, + }, +} + +/// Backoff bookkeeping for one candidate. +#[derive(Debug)] +struct DialState { + consecutive_failures: u32, + next_attempt: Instant, + in_flight: bool, + source: CandidateSource, +} + +impl DialState { + fn new(source: CandidateSource) -> Self { + Self { + consecutive_failures: 0, + next_attempt: Instant::now(), + in_flight: false, + source, + } + } +} + +/// Starts a network runtime. +pub(crate) fn spawn(params: RuntimeParams) -> NetworkHandle { + let keys = params.keys.clone(); + let shutdown = Shutdown::new(); + let (commands_tx, commands_rx) = mpsc::channel(64); + + let runtime_shutdown = shutdown.clone(); + let task = tokio::spawn(async move { + let mut runtime = Runtime::new(params, runtime_shutdown); + runtime.run(commands_rx).await; + }); + + NetworkHandle { + keys, + commands: commands_tx, + shutdown, + task, + } +} + +struct Runtime { + params: RuntimeParams, + network_id: NetworkId, + local_id: EndpointId, + shutdown: Shutdown, + sessions: HashMap, + dial_states: HashMap, + candidate_addrs: HashMap, + metrics: NetworkMetrics, + session_events_tx: mpsc::Sender, + session_events_rx: mpsc::Receiver, + dial_results_tx: mpsc::Sender, + dial_results_rx: mpsc::Receiver, +} + +impl Runtime { + fn new(params: RuntimeParams, shutdown: Shutdown) -> Self { + let network_id = params.keys.network_id(); + let local_id = params.adapter.endpoint_id(); + let (session_events_tx, session_events_rx) = mpsc::channel(256); + let (dial_results_tx, dial_results_rx) = mpsc::channel(64); + Self { + params, + network_id, + local_id, + shutdown, + sessions: HashMap::new(), + dial_states: HashMap::new(), + candidate_addrs: HashMap::new(), + metrics: NetworkMetrics::default(), + session_events_tx, + session_events_rx, + dial_results_tx, + dial_results_rx, + } + } + + fn emit(&self, event: Event) { + // A broadcast with no subscribers is not an error. + let _ = self.params.events.send(event); + } + + async fn run(&mut self, mut commands: mpsc::Receiver) { + let mut ticker = tokio::time::interval(self.params.discovery_interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + biased; + _ = self.shutdown.wait() => break, + command = commands.recv() => match command { + Some(command) => self.handle_command(command).await, + None => break, + }, + event = self.session_events_rx.recv() => { + if let Some(event) = event { + self.handle_session_event(event).await; + } + } + result = self.dial_results_rx.recv() => { + if let Some(result) = result { + self.handle_dial_result(result).await; + } + } + _ = ticker.tick() => self.discovery_round().await, + } + } + + self.teardown().await; + } + + async fn teardown(&mut self) { + // Stop accepting session events first: nothing is going to act on them + // any more, and a sender blocked on a full queue would stall shutdown. + self.session_events_rx.close(); + if let Some(discovery) = &self.params.discovery { + let _ = discovery + .unpublish(self.params.keys.discovery_key(), self.local_id) + .await; + } + let peers: Vec = self.sessions.keys().copied().collect(); + for peer in peers { + if let Some(session) = self.sessions.remove(&peer) { + session.stop().await; + } + } + self.emit(Event::NetworkDeactivated { + network: self.network_id, + }); + } + + // ---------------------------------------------------------------- commands + + async fn handle_command(&mut self, command: NetCommand) { + match command { + NetCommand::Inbound(inbound) => { + self.install_session(*inbound).await; + } + NetCommand::Send { + peer, + message, + reply, + } => { + let _ = reply.send(self.send_to(peer, message)); + } + NetCommand::Broadcast { message, reply } => { + let peers: Vec = self.sessions.keys().copied().collect(); + let mut delivered = 0; + for peer in peers { + if self.send_to(peer, message.clone()).is_ok() { + delivered += 1; + } + } + let _ = reply.send(delivered); + } + NetCommand::Status { reply } => { + let _ = reply.send(Box::new(self.status())); + } + NetCommand::Recheck => self.discovery_round().await, + } + } + + /// Queues a message without blocking the runtime loop. + /// + /// The envelope is encoded here so that the exact number of control bytes + /// handed to the transport is known and can be reported honestly. + /// + /// A full queue is backpressure: the send fails rather than stalling every + /// other peer in this network. + fn send_to(&mut self, peer: EndpointId, message: ControlMessage) -> Result<()> { + let network = self.network_id; + let envelope = Envelope { + network_id: *network.as_bytes(), + message, + }; + let encoded = encode(&envelope)?; + let bytes = encoded.len() as u64; + + let session = self.sessions.get_mut(&peer).ok_or(Error::NoSuchPeer { + network, + peer: peer.fmt_short().to_string(), + })?; + + match session.outbound.try_send(encoded) { + Ok(()) => { + session.messages_sent += 1; + session.bytes_sent += bytes; + self.metrics.control_messages_sent += 1; + self.metrics.control_bytes_sent += bytes; + Ok(()) + } + Err(mpsc::error::TrySendError::Full(_)) => Err(Error::Storage(format!( + "outbound queue for peer {} is full", + peer.fmt_short() + ))), + Err(mpsc::error::TrySendError::Closed(_)) => Err(Error::NoSuchPeer { + network, + peer: peer.fmt_short().to_string(), + }), + } + } + + // ------------------------------------------------------------- discovery + + async fn discovery_round(&mut self) { + if self.shutdown.is_triggered() { + return; + } + + let mut candidates: Vec = Vec::new(); + + if let Some(discovery) = self.params.discovery.clone() { + let key = self.params.keys.discovery_key(); + // Publishing every round keeps a restarted agent reachable at its + // new local port without any special case. + if let Err(err) = discovery.publish(key, self.params.adapter.addr()).await { + tracing::debug!(%err, "discovery publish failed"); + } + if let Err(err) = discovery + .publish(key, self.params.adapter.loopback_addr()) + .await + { + tracing::debug!(%err, "discovery publish of bound sockets failed"); + } + match discovery.resolve(key).await { + Ok(found) => candidates.extend(found), + Err(err) => tracing::debug!(%err, "discovery resolve failed"), + } + } + + // A stale or missing cache only changes which candidates we try first. + // It never bypasses authentication. + for hint in self.params.storage.hints_for_network(self.network_id).await { + let Ok(endpoint_id) = EndpointId::from_bytes(&hint.endpoint_id) else { + continue; + }; + let Some(addr) = decode_hint(endpoint_id, &hint.addr) else { + continue; + }; + candidates.push(Candidate::new(addr, CandidateSource::Cache)); + } + + for candidate in candidates { + let peer = candidate.endpoint_id(); + if peer == self.local_id { + continue; + } + self.candidate_addrs + .entry(peer) + .and_modify(|existing| merge_addr(existing, &candidate.addr)) + .or_insert_with(|| candidate.addr.clone()); + self.dial_states + .entry(peer) + .or_insert_with(|| DialState::new(candidate.source)); + } + + self.start_dials(); + } + + fn start_dials(&mut self) { + let now = Instant::now(); + let in_flight = self + .dial_states + .values() + .filter(|state| state.in_flight) + .count(); + let mut budget = self + .params + .limits + .max_concurrent_dials + .saturating_sub(in_flight); + if budget == 0 || self.sessions.len() >= self.params.limits.max_sessions_per_network { + return; + } + + let ready: Vec = self + .dial_states + .iter() + .filter(|(peer, state)| { + !state.in_flight && state.next_attempt <= now && !self.sessions.contains_key(*peer) + }) + .map(|(peer, _)| *peer) + .collect(); + + for peer in ready { + if budget == 0 { + break; + } + let Some(addr) = self.candidate_addrs.get(&peer).cloned() else { + continue; + }; + if let Some(state) = self.dial_states.get_mut(&peer) { + state.in_flight = true; + } + budget -= 1; + self.metrics.dial_attempts += 1; + + let adapter = self.params.adapter.clone(); + let keys = self.params.keys.clone(); + let limits = Arc::clone(&self.params.limits); + let results = self.dial_results_tx.clone(); + let local_id = self.local_id; + let shutdown = self.shutdown.clone(); + + tokio::spawn(async move { + let outcome = tokio::select! { + biased; + _ = shutdown.wait() => DialOutcome::Failed { + peer, + reason: "network deactivated".into(), + during_handshake: false, + }, + outcome = dial(adapter, addr, keys, limits, local_id, peer) => outcome, + }; + let _ = results.send(outcome).await; + }); + } + } + + async fn handle_dial_result(&mut self, result: DialOutcome) { + match result { + DialOutcome::Established(inbound) => { + let peer = inbound.outcome.peer; + if let Some(state) = self.dial_states.get_mut(&peer) { + state.in_flight = false; + state.consecutive_failures = 0; + state.next_attempt = Instant::now(); + } + self.install_session(*inbound).await; + } + DialOutcome::Failed { + peer, + reason, + during_handshake, + } => { + self.metrics.dial_failures += 1; + if during_handshake { + self.metrics.handshake_failures += 1; + } + let give_up = { + let policy = &self.params.reconnect; + let state = self + .dial_states + .entry(peer) + .or_insert_with(|| DialState::new(CandidateSource::Discovery)); + state.in_flight = false; + state.consecutive_failures = state.consecutive_failures.saturating_add(1); + let delay = policy.delay_for(state.consecutive_failures); + state.next_attempt = Instant::now() + delay; + policy + .max_consecutive_failures + .is_some_and(|max| state.consecutive_failures >= max) + }; + if give_up { + // Keep the backoff entry but push it far out; discovery + // seeing the peer again resets it. + if let Some(state) = self.dial_states.get_mut(&peer) { + state.next_attempt = Instant::now() + self.params.reconnect.max_delay; + } + } + self.emit(Event::DialFailed { + network: self.network_id, + peer, + reason, + }); + } + } + } + + // --------------------------------------------------------------- sessions + + async fn install_session(&mut self, inbound: InboundSession) { + let InboundSession { + conn, + send, + recv, + outcome, + } = inbound; + let peer = outcome.peer; + + if self.sessions.len() >= self.params.limits.max_sessions_per_network + && !self.sessions.contains_key(&peer) + { + conn.close(1u32.into(), b"session limit reached"); + self.emit(Event::ProtocolViolation { + network: Some(self.network_id), + peer: Some(peer), + reason: "session limit for this network reached".into(), + }); + return; + } + + // Two agents may dial each other at the same time. Both sides apply the + // same deterministic rule, so they converge on the same session. + if let Some(existing) = self.sessions.get(&peer) { + let existing_initiator = initiator_of(existing.role, self.local_id, peer); + let new_initiator = initiator_of(outcome.role, self.local_id, peer); + if existing_initiator.as_bytes() <= new_initiator.as_bytes() { + conn.close(0u32.into(), b"duplicate session"); + return; + } + if let Some(old) = self.sessions.remove(&peer) { + old.abort(); + old.conn + .close(0u32.into(), b"replaced by preferred session"); + } + } + + self.record_hints(&conn).await; + + let session = session::spawn( + self.network_id, + peer, + outcome.role, + conn.clone(), + send, + recv, + Arc::clone(&self.params.limits), + self.session_events_tx.clone(), + self.shutdown.clone(), + ); + + let snapshot = snapshot_connection(&conn); + self.sessions.insert(peer, session); + self.metrics.sessions_established += 1; + + // Announce ourselves straight away so the peer learns our hostname and + // capabilities without another round of discovery. + let announcement = ControlMessage::Announce(self.local_announcement()); + if let Err(err) = self.send_to(peer, announcement) { + tracing::debug!(%err, "could not queue initial announcement"); + } + + self.emit(Event::PeerConnected { + network: self.network_id, + peer, + role: outcome.role, + transport: snapshot.transport, + rtt: snapshot.rtt, + }); + } + + fn local_announcement(&mut self) -> Announcement { + let mut capabilities = Vec::new(); + let mut errors = Vec::new(); + for plugin in &self.params.plugins { + match plugin.local_capability(self.network_id) { + Ok(Some(capability)) => capabilities.push(capability), + Ok(None) => {} + Err(err) => errors.push((plugin.protocol_id().to_string(), err.to_string())), + } + } + for (protocol, reason) in errors { + self.metrics.plugin_errors += 1; + self.emit(Event::PluginError { + network: self.network_id, + protocol, + reason, + }); + } + capabilities.truncate(self.params.limits.max_capabilities); + Announcement { + hostname: self.params.hostname.clone(), + capabilities, + } + } + + async fn record_hints(&self, conn: &Connection) { + let snapshot = snapshot_connection(conn); + let peer_bytes = *snapshot.remote_id.as_bytes(); + for path in &snapshot.paths { + if let Some(encoded) = encode_hint(&path.remote) { + self.params + .storage + .record_hint( + self.network_id, + peer_bytes, + encoded, + self.params.limits.max_hints_per_peer, + ) + .await; + } + } + } + + async fn handle_session_event(&mut self, event: SessionEvent) { + match event { + SessionEvent::Message { + session_id, + peer, + message, + bytes, + } => { + let current = self.sessions.get(&peer).map(|session| session.id); + if current != Some(session_id) { + return; + } + self.metrics.control_messages_received += 1; + self.metrics.control_bytes_received += bytes as u64; + if let Some(session) = self.sessions.get_mut(&peer) { + session.messages_received += 1; + session.bytes_received += bytes as u64; + } + self.dispatch_message(peer, message); + } + SessionEvent::Violation { + session_id, + peer, + error, + } => { + let current = self.sessions.get(&peer).map(|session| session.id); + if current != Some(session_id) { + return; + } + self.metrics.protocol_violations += 1; + self.emit(Event::ProtocolViolation { + network: Some(self.network_id), + peer: Some(peer), + reason: error.to_string(), + }); + } + SessionEvent::Closed { + session_id, + peer, + reason, + } => { + let current = self.sessions.get(&peer).map(|session| session.id); + if current != Some(session_id) { + return; + } + if let Some(session) = self.sessions.remove(&peer) { + session.abort(); + session.conn.close(0u32.into(), b"session ended"); + } + self.metrics.disconnects += 1; + for plugin in &self.params.plugins { + plugin.on_peer_gone(self.network_id, peer); + } + // Retry promptly, then back off if it keeps failing. + let policy = &self.params.reconnect; + let state = self + .dial_states + .entry(peer) + .or_insert_with(|| DialState::new(CandidateSource::Discovery)); + state.in_flight = false; + state.next_attempt = Instant::now() + policy.initial_delay; + self.emit(Event::PeerDisconnected { + network: self.network_id, + peer, + reason, + }); + } + } + } + + fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) { + match &message { + ControlMessage::Announce(announcement) => { + let capabilities = announcement.capabilities.clone(); + if let Some(session) = self.sessions.get_mut(&peer) { + session.hostname = Some(announcement.hostname.clone()); + session.capabilities = capabilities.clone(); + } + self.dispatch_capabilities(peer, &capabilities); + } + ControlMessage::Ping { seq, payload } => { + let pong = ControlMessage::Pong { + seq: *seq, + payload: payload.clone(), + }; + if let Err(err) = self.send_to(peer, pong) { + tracing::debug!(%err, "could not queue pong"); + } + } + ControlMessage::Pong { .. } | ControlMessage::Bye { .. } => {} + } + + self.emit(Event::MessageReceived { + network: self.network_id, + peer, + message, + }); + } + + fn dispatch_capabilities(&mut self, peer: EndpointId, capabilities: &[PluginCapability]) { + let mut errors = Vec::new(); + for capability in capabilities { + for plugin in &self.params.plugins { + if plugin.protocol_id() != capability.protocol { + continue; + } + // The core hands the opaque payload over without interpreting it. + if let Err(err) = plugin.on_peer_capability(self.network_id, peer, capability) { + errors.push((plugin.protocol_id().to_string(), err.to_string())); + } + } + } + for (protocol, reason) in errors { + self.metrics.plugin_errors += 1; + self.emit(Event::PluginError { + network: self.network_id, + protocol, + reason, + }); + } + } + + // ----------------------------------------------------------------- status + + fn status(&self) -> NetworkStatus { + let mut peers: Vec = self + .sessions + .values() + .map(|session| { + let snapshot = snapshot_connection(&session.conn); + PeerStatus { + endpoint_id: session.peer, + role: session.role, + hostname: session.hostname.clone(), + capabilities: session.capabilities.clone(), + connected_for: session.established.elapsed(), + paths: snapshot.paths, + transport: snapshot.transport, + rtt: snapshot.rtt, + connection: snapshot.counters, + control_messages_sent: session.messages_sent, + control_messages_received: session.messages_received, + control_bytes_sent: session.bytes_sent, + control_bytes_received: session.bytes_received, + } + }) + .collect(); + peers.sort_by(|a, b| a.endpoint_id.as_bytes().cmp(b.endpoint_id.as_bytes())); + + let mut candidates: Vec = self + .dial_states + .iter() + .map(|(peer, state)| CandidateStatus { + endpoint_id: *peer, + source: state.source, + consecutive_failures: state.consecutive_failures, + }) + .collect(); + candidates.sort_by(|a, b| a.endpoint_id.as_bytes().cmp(b.endpoint_id.as_bytes())); + + NetworkStatus { + descriptor: self.params.keys.descriptor(), + name: self.params.keys.name().clone(), + network_id: self.network_id, + state: NetworkState::Active, + peers, + candidates, + metrics: self.metrics.clone(), + } + } +} + +/// Which side dialled, given a role and the two identities. +fn initiator_of(role: Role, local: EndpointId, peer: EndpointId) -> EndpointId { + match role { + Role::Initiator => local, + Role::Responder => peer, + } +} + +/// Performs one dial and handshake. +async fn dial( + adapter: EndpointAdapter, + addr: iroh::EndpointAddr, + keys: NetworkKeys, + limits: Arc, + local_id: EndpointId, + peer: EndpointId, +) -> DialOutcome { + let connect = tokio::time::timeout(limits.dial_timeout, adapter.connect(addr)).await; + let (conn, mut send, mut recv) = match connect { + Ok(Ok(parts)) => parts, + Ok(Err(err)) => { + return DialOutcome::Failed { + peer, + reason: err.to_string(), + during_handshake: false, + }; + } + Err(_) => { + return DialOutcome::Failed { + peer, + reason: "dial timed out".into(), + during_handshake: false, + }; + } + }; + + match handshake::initiate(&conn, &mut send, &mut recv, local_id, &keys, &limits).await { + Ok(outcome) => DialOutcome::Established(Box::new(InboundSession { + conn, + send, + recv, + outcome, + })), + Err(err) => { + conn.close(2u32.into(), b"handshake failed"); + DialOutcome::Failed { + peer, + reason: err.to_string(), + during_handshake: true, + } + } + } +} + +/// Encodes a path address as a cache hint. +fn encode_hint(addr: &PathAddr) -> Option { + match addr { + PathAddr::Ip(socket) => Some(format!("ip:{socket}")), + PathAddr::Relay(url) => Some(format!("relay:{url}")), + PathAddr::Other(_) => None, + } +} + +/// Decodes a cache hint back into an address. Malformed hints are ignored. +fn decode_hint(endpoint_id: EndpointId, hint: &str) -> Option { + if let Some(rest) = hint.strip_prefix("ip:") { + let socket: std::net::SocketAddr = rest.parse().ok()?; + return Some(iroh::EndpointAddr::new(endpoint_id).with_ip_addr(socket)); + } + if let Some(rest) = hint.strip_prefix("relay:") { + let url: iroh::RelayUrl = rest.parse().ok()?; + return Some(iroh::EndpointAddr::new(endpoint_id).with_relay_url(url)); + } + None +} + +/// Folds newly learned addresses into a known candidate address. +fn merge_addr(existing: &mut iroh::EndpointAddr, incoming: &iroh::EndpointAddr) { + if existing.id != incoming.id { + *existing = incoming.clone(); + return; + } + for addr in &incoming.addrs { + existing.addrs.insert(addr.clone()); + } +} diff --git a/src/agent/session.rs b/src/agent/session.rs new file mode 100644 index 0000000..9d95f85 --- /dev/null +++ b/src/agent/session.rs @@ -0,0 +1,337 @@ +//! One authenticated session with one peer, in one network. +//! +//! A session owns a reader task and a writer task over a single QUIC +//! bidirectional stream. Splitting them keeps both halves simple and avoids +//! cancelling a partially consumed frame, which stream reads do not tolerate. +//! +//! Every inbound message is re-checked against the session's network id, so an +//! authenticated session for one network can never deliver into another. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use iroh::EndpointId; +use iroh::endpoint::{Connection, RecvStream, SendStream}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::config::Limits; +use crate::dataplane::PluginCapability; +use crate::error::ProtocolError; +use crate::identity::NetworkId; +use crate::proto::handshake::Role; +use crate::proto::message::{ControlMessage, Envelope, decode, validate}; +use crate::proto::{read_frame, write_frame}; + +use super::shutdown::Shutdown; + +/// What a session task reports back to its network runtime. +#[derive(Debug)] +pub(crate) enum SessionEvent { + /// A valid control message arrived. + Message { + /// Which session instance produced this. + session_id: u64, + /// The peer. + peer: EndpointId, + /// The decoded message. + message: ControlMessage, + /// Payload bytes read off the wire. + bytes: usize, + }, + /// The peer sent something the protocol does not allow. + Violation { + /// Which session instance produced this. + session_id: u64, + /// The peer. + peer: EndpointId, + /// What was wrong. + error: ProtocolError, + }, + /// The session ended. + Closed { + /// Which session instance ended. + session_id: u64, + /// The peer. + peer: EndpointId, + /// Why it ended. + reason: String, + }, +} + +/// A live session, as held by the network runtime. +#[derive(Debug)] +pub(crate) struct Session { + pub(crate) id: u64, + pub(crate) peer: EndpointId, + pub(crate) role: Role, + pub(crate) established: Instant, + pub(crate) conn: Connection, + /// Already encoded frame payloads, so the runtime can account for the exact + /// number of control bytes it queues. + pub(crate) outbound: mpsc::Sender>, + pub(crate) hostname: Option, + pub(crate) capabilities: Vec, + pub(crate) messages_sent: u64, + pub(crate) messages_received: u64, + pub(crate) bytes_sent: u64, + pub(crate) bytes_received: u64, + reader: JoinHandle<()>, + writer: JoinHandle<()>, + shutdown: Shutdown, +} + +impl Session { + /// Signals both tasks to stop and waits for them, with a bounded grace + /// period. + /// + /// A peer that stops reading must not be able to hold up shutdown, so the + /// tasks are aborted if they do not wind down in time. + pub(crate) async fn stop(self) { + let Session { + conn, + reader, + writer, + shutdown, + .. + } = self; + shutdown.trigger(); + // Closing the connection unblocks a reader parked on the stream. + conn.close(0u32.into(), b"session stopped by local agent"); + + let reader_abort = reader.abort_handle(); + let writer_abort = writer.abort_handle(); + let joined = tokio::time::timeout(STOP_GRACE, async move { + let _ = reader.await; + let _ = writer.await; + }) + .await; + if joined.is_err() { + tracing::debug!("session tasks did not wind down in time; aborting them"); + reader_abort.abort(); + writer_abort.abort(); + } + } + + /// Aborts both tasks without waiting. Used on replacement. + pub(crate) fn abort(&self) { + self.shutdown.trigger(); + self.reader.abort(); + self.writer.abort(); + } +} + +/// How long a stopping session may take to wind down before its tasks are +/// aborted. Shutdown must be bounded even if a peer stops reading. +const STOP_GRACE: std::time::Duration = std::time::Duration::from_millis(500); + +/// Source of monotonically increasing session instance ids. +static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); + +/// Starts the reader and writer tasks for an authenticated stream. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn( + network_id: NetworkId, + peer: EndpointId, + role: Role, + conn: Connection, + send: SendStream, + recv: RecvStream, + limits: Arc, + events: mpsc::Sender, + parent_shutdown: Shutdown, +) -> Session { + let id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed); + let shutdown = Shutdown::new(); + let (outbound_tx, outbound_rx) = mpsc::channel(limits.session_send_queue); + + let writer = tokio::spawn(writer_task( + send, + outbound_rx, + Arc::clone(&limits), + shutdown.clone(), + parent_shutdown.clone(), + )); + + let reader = tokio::spawn(reader_task( + id, + network_id, + peer, + recv, + limits, + events, + shutdown.clone(), + parent_shutdown, + )); + + Session { + id, + peer, + role, + established: Instant::now(), + conn, + outbound: outbound_tx, + hostname: None, + capabilities: Vec::new(), + messages_sent: 0, + messages_received: 0, + bytes_sent: 0, + bytes_received: 0, + reader, + writer, + shutdown, + } +} + +async fn writer_task( + mut send: SendStream, + mut outbound: mpsc::Receiver>, + limits: Arc, + shutdown: Shutdown, + parent: Shutdown, +) { + loop { + let encoded = tokio::select! { + biased; + _ = shutdown.wait() => break, + _ = parent.wait() => break, + encoded = outbound.recv() => match encoded { + Some(encoded) => encoded, + None => break, + }, + }; + + // The write must stay cancellable: shutting the session down cannot + // wait for a peer that has stopped reading. Abandoning a half written + // frame is fine, because the session is going away with it. + let write = tokio::time::timeout( + limits.write_timeout, + write_frame(&mut send, &encoded, limits.max_frame_len), + ); + let result = tokio::select! { + biased; + _ = shutdown.wait() => break, + _ = parent.wait() => break, + result = write => result, + }; + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::debug!(%err, "control stream write failed"); + break; + } + Err(_) => { + tracing::debug!("control stream write timed out"); + break; + } + } + } + let _ = send.finish(); +} + +#[allow(clippy::too_many_arguments)] +async fn reader_task( + session_id: u64, + network_id: NetworkId, + peer: EndpointId, + mut recv: RecvStream, + limits: Arc, + events: mpsc::Sender, + shutdown: Shutdown, + parent: Shutdown, +) { + let reason = loop { + let frame = tokio::select! { + biased; + _ = shutdown.wait() => break "stopped locally".to_string(), + _ = parent.wait() => break "network deactivated".to_string(), + frame = read_frame(&mut recv, limits.max_frame_len) => frame, + }; + + let payload = match frame { + Ok(payload) => payload, + Err(ProtocolError::StreamClosed) => break "peer closed the control stream".to_string(), + Err(ProtocolError::Stream(err)) => { + // The transport went away. That is a disconnect, not a peer + // misbehaving, so it ends the session without being counted as + // a protocol violation. + break format!("control stream error: {err}"); + } + Err(err) => { + // A framing violation ends this session. Framing errors are not + // recoverable mid-stream: the next bytes have no known meaning. + let text = err.to_string(); + let _ = events + .send(SessionEvent::Violation { + session_id, + peer, + error: err, + }) + .await; + break text; + } + }; + + let bytes = payload.len(); + let envelope: Envelope = match decode(&payload) { + Ok(envelope) => envelope, + Err(err) => { + let _ = events + .send(SessionEvent::Violation { + session_id, + peer, + error: err, + }) + .await; + break "malformed control frame".to_string(); + } + }; + + // Network isolation: a session authenticated for one network must never + // deliver a message belonging to another. + if envelope.network_id != *network_id.as_bytes() { + let _ = events + .send(SessionEvent::Violation { + session_id, + peer, + error: ProtocolError::NetworkMismatch, + }) + .await; + break "network id mismatch on an authenticated session".to_string(); + } + + if let Err(err) = validate(&envelope.message, &limits) { + let _ = events + .send(SessionEvent::Violation { + session_id, + peer, + error: err, + }) + .await; + continue; + } + + if events + .send(SessionEvent::Message { + session_id, + peer, + message: envelope.message, + bytes, + }) + .await + .is_err() + { + break "network runtime stopped".to_string(); + } + }; + + // Best effort: the runtime may already have stopped draining this channel + // while it tears the network down, and a closing session must not block on + // that. + let _ = events.try_send(SessionEvent::Closed { + session_id, + peer, + reason, + }); +} diff --git a/src/agent/shutdown.rs b/src/agent/shutdown.rs new file mode 100644 index 0000000..f9bbd5b --- /dev/null +++ b/src/agent/shutdown.rs @@ -0,0 +1,53 @@ +//! A minimal cancellation primitive. +//! +//! Kept local so the crate does not pull in a utility dependency for one type, +//! and so that no global state is involved: every agent and every network +//! runtime owns its own token. + +use std::sync::Arc; + +use tokio::sync::watch; + +/// A clonable cancellation token. +#[derive(Debug, Clone)] +pub(crate) struct Shutdown { + tx: Arc>, + rx: watch::Receiver, +} + +impl Shutdown { + /// Creates an untriggered token. + pub(crate) fn new() -> Self { + let (tx, rx) = watch::channel(false); + Self { + tx: Arc::new(tx), + rx, + } + } + + /// Triggers cancellation. Idempotent. + pub(crate) fn trigger(&self) { + let _ = self.tx.send(true); + } + + /// Whether cancellation has been triggered. + pub(crate) fn is_triggered(&self) -> bool { + *self.rx.borrow() + } + + /// Resolves once cancellation has been triggered. + pub(crate) async fn wait(&self) { + let mut rx = self.rx.clone(); + { + if *rx.borrow() { + return; + } + } + while rx.changed().await.is_ok() { + if *rx.borrow() { + return; + } + } + // The sender is gone, which for our purposes means "stop". + } +} diff --git a/src/agent/status.rs b/src/agent/status.rs new file mode 100644 index 0000000..3dd45fc --- /dev/null +++ b/src/agent/status.rs @@ -0,0 +1,153 @@ +//! Status snapshots. +//! +//! Metrics are reported at the level they actually belong to. Values that +//! genuinely cannot be attributed to a single network — everything the iroh +//! endpoint aggregates, for instance — stay at the endpoint level rather than +//! being split between networks with invented precision. + +use std::time::Duration; + +use iroh::{EndpointAddr, EndpointId}; + +use crate::dataplane::PluginCapability; +use crate::discovery::CandidateSource; +use crate::identity::{NetworkDescriptor, NetworkId, NetworkName}; +use crate::net::{ConnectionCounters, PathAddr, PathInfo, TransportKind}; +use crate::proto::handshake::Role; +use crate::storage::CacheOutcome; + +/// Whether a configured network is running locally. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkState { + /// Configured and running. + Active, + /// Configured but not running. + Inactive, +} + +/// An unverified candidate as seen by a network runtime. +#[derive(Debug, Clone)] +pub struct CandidateStatus { + /// Candidate endpoint id. + pub endpoint_id: EndpointId, + /// Where it came from. + pub source: CandidateSource, + /// Consecutive failed dial attempts since the last success. + pub consecutive_failures: u32, +} + +/// Status of one authenticated session. +#[derive(Debug, Clone)] +pub struct PeerStatus { + /// Authenticated endpoint id. + pub endpoint_id: EndpointId, + /// Which side this agent played in the handshake. + pub role: Role, + /// Hostname the peer announced, if it has announced one yet. + /// + /// A mutable binding, not an identity. + pub hostname: Option, + /// Capabilities the peer announced. Payloads stay opaque. + pub capabilities: Vec, + /// How long the session has been up. + pub connected_for: Duration, + /// Verified paths of the underlying connection. + pub paths: Vec, + /// How the connection currently reaches the peer. + pub transport: TransportKind, + /// RTT of the selected path, when iroh reported one. + pub rtt: Option, + /// Per-connection counters. + pub connection: ConnectionCounters, + /// Control messages sent on this session. + pub control_messages_sent: u64, + /// Control messages received on this session. + pub control_messages_received: u64, + /// Control payload bytes queued for this session. + pub control_bytes_sent: u64, + /// Control payload bytes read from this session. + pub control_bytes_received: u64, +} + +/// Counters scoped to one logical network. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkMetrics { + /// Outbound dial attempts started. + pub dial_attempts: u64, + /// Outbound dials that failed before or during the handshake. + pub dial_failures: u64, + /// Handshakes rejected, in either direction. + pub handshake_failures: u64, + /// Sessions that reached the authenticated state. + pub sessions_established: u64, + /// Sessions that ended. + pub disconnects: u64, + /// Control messages sent in this network. + pub control_messages_sent: u64, + /// Control messages received in this network. + pub control_messages_received: u64, + /// Control bytes sent in this network, payload only. + pub control_bytes_sent: u64, + /// Control bytes received in this network, payload only. + pub control_bytes_received: u64, + /// Messages or sessions rejected for protocol violations. + pub protocol_violations: u64, + /// Errors reported by IP plugins. Never fatal. + pub plugin_errors: u64, +} + +/// Status of one network. +#[derive(Debug, Clone)] +pub struct NetworkStatus { + /// Immutable deterministic description of the network space. + pub descriptor: NetworkDescriptor, + /// Network name, for convenience. + pub name: NetworkName, + /// Public network identifier. + pub network_id: NetworkId, + /// Whether the network is running locally. + pub state: NetworkState, + /// Authenticated sessions. + pub peers: Vec, + /// Unverified candidates currently known. Not peers. + pub candidates: Vec, + /// Per-network counters. + pub metrics: NetworkMetrics, +} + +impl NetworkStatus { + /// Endpoint ids of peers with an authenticated session. + pub fn connected_peers(&self) -> Vec { + self.peers.iter().map(|peer| peer.endpoint_id).collect() + } +} + +/// Status of the whole agent. +#[derive(Debug, Clone)] +pub struct AgentStatus { + /// This device's persistent endpoint id. + pub endpoint_id: EndpointId, + /// Hostname announced to peers. + pub hostname: String, + /// Sockets actually bound. + pub bound_sockets: Vec, + /// Addresses iroh believes this endpoint has. Observed, not verified. + pub observed_addrs: Vec, + /// The dialable address of this endpoint, as iroh currently reports it. + pub endpoint_addr: EndpointAddr, + /// What happened to the disposable cache at startup. + pub cache_outcome: CacheOutcome, + /// Whether the cache is currently usable. + pub cache_healthy: bool, + /// Per-network status, including configured but inactive networks. + pub networks: Vec, +} + +impl AgentStatus { + /// Looks up one network's status. + pub fn network(&self, network_id: &NetworkId) -> Option<&NetworkStatus> { + self.networks + .iter() + .find(|status| &status.network_id == network_id) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..642bcf7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,327 @@ +//! Library configuration. +//! +//! Everything the agent needs is passed in explicitly. The library reads no +//! environment variables, installs no global state and picks no default +//! directories behind the caller's back — [`StoragePaths::user_default`] exists +//! but must be called on purpose. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use crate::dataplane::SharedPlugin; +use crate::discovery::NetworkDiscovery; +use crate::error::{Error, Result}; + +/// Qualifier/organisation/application triple used for platform directories. +const APP_NAME: &str = "tsunagi"; + +/// Where the two stores live. +/// +/// The mandatory state and the disposable cache are separate both logically and +/// physically, so that the cache can be deleted at any time without touching +/// identity or network configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoragePaths { + /// Directory holding `state.sqlite` and the ownership lock. + pub state_dir: PathBuf, + /// Directory holding `cache.sqlite`. + pub cache_dir: PathBuf, +} + +impl StoragePaths { + /// Uses explicit directories. Tests always use temporary directories. + pub fn new(state_dir: impl Into, cache_dir: impl Into) -> Self { + Self { + state_dir: state_dir.into(), + cache_dir: cache_dir.into(), + } + } + + /// Puts both stores under one root, in `state/` and `cache/` subdirectories. + pub fn under(root: impl AsRef) -> Self { + let root = root.as_ref(); + Self { + state_dir: root.join("state"), + cache_dir: root.join("cache"), + } + } + + /// The per-user platform directories. + /// + /// A future system service can supply its own paths instead. + pub fn user_default() -> Result { + let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| { + Error::Storage("no valid home directory for platform config paths".into()) + })?; + Ok(Self { + state_dir: dirs.data_dir().to_path_buf(), + cache_dir: dirs.cache_dir().to_path_buf(), + }) + } + + /// Path of the mandatory state database. + pub fn state_db(&self) -> PathBuf { + self.state_dir.join("state.sqlite") + } + + /// Path of the disposable cache database. + pub fn cache_db(&self) -> PathBuf { + self.cache_dir.join("cache.sqlite") + } + + /// Path of the ownership lock file. + pub fn lock_file(&self) -> PathBuf { + self.state_dir.join("state.lock") + } +} + +/// How the iroh endpoint is allowed to reach the outside world. +/// +/// The default is [`TransportPolicy::LocalOnly`] so that a plain +/// `AgentConfig::new(...)` never reaches the internet by accident. Callers that +/// want public connectivity must opt in. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TransportPolicy { + /// No relays, no address lookup service, no port mapping. + /// + /// Suitable for tests and for fully local deployments. + #[default] + LocalOnly, + /// No relays, but the n0 DNS/pkarr address lookup is enabled. + DirectOnly, + /// iroh's standard behaviour, including the public n0 relays. + /// + /// Public relays are fine for development; they carry no availability + /// guarantee. + N0Defaults, +} + +/// Bounds applied to everything that comes off the network. +/// +/// Each of these is enforced before memory is allocated for the corresponding +/// object where that is possible (notably [`Limits::max_frame_len`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Limits { + /// Largest accepted control frame payload, in bytes. + pub max_frame_len: usize, + /// Largest accepted hostname, in bytes. + pub max_hostname_len: usize, + /// Largest number of plugin capabilities in one announcement. + pub max_capabilities: usize, + /// Largest opaque plugin payload, in bytes. + pub max_capability_data_len: usize, + /// Largest accepted echo payload in a ping/pong exchange, in bytes. + pub max_echo_payload_len: usize, + /// Largest accepted free-text reason string, in bytes. + pub max_reason_len: usize, + /// Deadline for the whole handshake. + pub handshake_timeout: Duration, + /// Deadline for one outbound dial attempt. + pub dial_timeout: Duration, + /// Deadline for writing one control frame. + /// + /// Liveness of an established session is delegated to QUIC: iroh configures + /// keep-alives and an idle timeout, so a dead peer surfaces as a read error + /// rather than needing a protocol-level heartbeat here. + pub write_timeout: Duration, + /// Maximum simultaneous outbound dials per network. + pub max_concurrent_dials: usize, + /// Maximum simultaneous authenticated sessions per network. + pub max_sessions_per_network: usize, + /// Maximum simultaneous inbound connections being handshaken. + pub max_inbound_handshakes: usize, + /// Capacity of a session's outbound queue, providing backpressure. + pub session_send_queue: usize, + /// Capacity of the event broadcast channel. + pub event_buffer: usize, + /// Maximum address hints kept per peer in the cache. + pub max_hints_per_peer: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_frame_len: 64 * 1024, + max_hostname_len: 255, + max_capabilities: 16, + max_capability_data_len: 4 * 1024, + max_echo_payload_len: 4 * 1024, + max_reason_len: 256, + handshake_timeout: Duration::from_secs(10), + dial_timeout: Duration::from_secs(10), + write_timeout: Duration::from_secs(30), + max_concurrent_dials: 8, + max_sessions_per_network: 64, + max_inbound_handshakes: 32, + session_send_queue: 64, + event_buffer: 512, + max_hints_per_peer: 8, + } + } +} + +/// Bounded exponential backoff with jitter for reconnect attempts. +#[derive(Debug, Clone, PartialEq)] +pub struct ReconnectPolicy { + /// Delay before the first retry. + pub initial_delay: Duration, + /// Upper bound on the delay. + pub max_delay: Duration, + /// Multiplier applied after each failed attempt. + pub factor: f64, + /// Fraction of the delay applied as random jitter, in `0.0..=1.0`. + pub jitter: f64, + /// Give up on a peer after this many consecutive failures until it is seen + /// again by discovery. `None` means never give up while the network is up. + pub max_consecutive_failures: Option, +} + +impl Default for ReconnectPolicy { + fn default() -> Self { + Self { + initial_delay: Duration::from_millis(250), + max_delay: Duration::from_secs(30), + factor: 2.0, + jitter: 0.3, + max_consecutive_failures: None, + } + } +} + +impl ReconnectPolicy { + /// Delay to wait before retry number `attempt` (1-based), with jitter. + pub(crate) fn delay_for(&self, attempt: u32) -> Duration { + let exp = self.factor.powi(attempt.saturating_sub(1).min(32) as i32); + let base = self.initial_delay.as_secs_f64() * exp; + let capped = base.min(self.max_delay.as_secs_f64()); + let jitter = self.jitter.clamp(0.0, 1.0); + let factor = 1.0 - jitter + jitter * 2.0 * rand::random::(); + Duration::from_secs_f64((capped * factor).max(0.0)) + } +} + +/// Everything needed to start an [`crate::Agent`]. +#[derive(Clone)] +pub struct AgentConfig { + /// Where the mandatory state and the disposable cache live. + pub paths: StoragePaths, + /// Explicit local bind addresses. Empty means iroh's defaults. + /// + /// Tests bind to `127.0.0.1:0` so each agent gets a dynamic port. + pub bind_addrs: Vec, + /// How much external connectivity machinery the endpoint may use. + pub transport: TransportPolicy, + /// Hostname announced to peers. `None` keeps whatever the state store holds, + /// falling back to the OS hostname and finally to a short endpoint id. + pub hostname: Option, + /// Discovery backend. `None` disables discovery-driven dialling; static + /// bootstrap candidates still work. + pub discovery: Option>, + /// How often each active network re-runs discovery and re-evaluates dials. + pub discovery_interval: Duration, + /// Bounds applied to network input. + pub limits: Limits, + /// Reconnect backoff policy. + pub reconnect: ReconnectPolicy, + /// IP plugins whose capabilities are announced and dispatched. + pub plugins: Vec, +} + +impl AgentConfig { + /// Creates a configuration with local-only transport and default limits. + pub fn new(paths: StoragePaths) -> Self { + Self { + paths, + bind_addrs: Vec::new(), + transport: TransportPolicy::default(), + hostname: None, + discovery: None, + discovery_interval: Duration::from_secs(5), + limits: Limits::default(), + reconnect: ReconnectPolicy::default(), + plugins: Vec::new(), + } + } + + /// Binds to loopback with a dynamic port. Used by the test suite. + pub fn with_loopback_bind(mut self) -> Self { + self.bind_addrs = vec![ + SocketAddr::from(([127, 0, 0, 1], 0)), + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0)), + ]; + self + } + + /// Sets an explicit list of bind addresses. + pub fn with_bind_addrs(mut self, addrs: impl IntoIterator) -> Self { + self.bind_addrs = addrs.into_iter().collect(); + self + } + + /// Sets the transport policy. + pub fn with_transport(mut self, transport: TransportPolicy) -> Self { + self.transport = transport; + self + } + + /// Sets the discovery backend. + pub fn with_discovery(mut self, discovery: Arc) -> Self { + self.discovery = Some(discovery); + self + } + + /// Sets how often discovery runs. + pub fn with_discovery_interval(mut self, interval: Duration) -> Self { + self.discovery_interval = interval; + self + } + + /// Sets the announced hostname. + pub fn with_hostname(mut self, hostname: impl Into) -> Self { + self.hostname = Some(hostname.into()); + self + } + + /// Registers an IP plugin. + pub fn with_plugin(mut self, plugin: SharedPlugin) -> Self { + self.plugins.push(plugin); + self + } + + /// Replaces the limits. + pub fn with_limits(mut self, limits: Limits) -> Self { + self.limits = limits; + self + } + + /// Replaces the reconnect policy. + pub fn with_reconnect(mut self, reconnect: ReconnectPolicy) -> Self { + self.reconnect = reconnect; + self + } +} + +impl std::fmt::Debug for AgentConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgentConfig") + .field("paths", &self.paths) + .field("bind_addrs", &self.bind_addrs) + .field("transport", &self.transport) + .field("hostname", &self.hostname) + .field("discovery", &self.discovery.as_ref().map(|d| d.name())) + .field("discovery_interval", &self.discovery_interval) + .field("limits", &self.limits) + .field("reconnect", &self.reconnect) + .field( + "plugins", + &self + .plugins + .iter() + .map(|p| p.protocol_id().to_string()) + .collect::>(), + ) + .finish() + } +} diff --git a/src/dataplane.rs b/src/dataplane.rs new file mode 100644 index 0000000..9d4ee86 --- /dev/null +++ b/src/dataplane.rs @@ -0,0 +1,168 @@ +//! Boundary between the control plane core and future IP plugins. +//! +//! The data plane is where actual IP connectivity is created. WireGuard is the +//! first planned plugin; none is implemented here. +//! +//! Two rules shape this module: +//! +//! 1. **The core never parses plugin payloads.** A [`PluginCapability`] carries +//! a protocol id, a version, an enabled flag and a bounded opaque blob. The +//! core transports the blob and hands it to the matching plugin. It does not +//! know what a WireGuard configuration looks like. +//! 2. **Plugin keys and lifecycle are separate from iroh identity and from the +//! network secret.** A plugin owns its own keys and its own system objects. +//! +//! An iroh address is *not* automatically a WireGuard address. A future plugin +//! is expected to gather its own reachability information and ship it through +//! the control plane as its announcement payload. +//! +//! A data plane failure never stops the daemon: errors returned here are +//! recorded and surfaced, the control plane keeps running. + +use std::sync::Arc; + +use iroh::EndpointId; + +use crate::identity::NetworkId; + +/// Maximum length of a plugin protocol identifier. +pub const MAX_PROTOCOL_ID_LEN: usize = 32; + +/// An announcement of one IP plugin's capability. +/// +/// `data` is opaque to the core. Nothing in it may be interpreted as a shell +/// command, a filesystem path or an OS setting by the core; a plugin that +/// chooses to do so must validate it itself. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct PluginCapability { + /// Protocol identifier, e.g. `wireguard`. Bounded by [`MAX_PROTOCOL_ID_LEN`]. + pub protocol: String, + /// Version of the plugin's announcement format. + pub version: u16, + /// Whether the peer currently has this plugin enabled. + pub enabled: bool, + /// Opaque, bounded, plugin-defined payload. + pub data: Vec, +} + +/// Errors a plugin may return. They are recorded, never fatal for the agent. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum PluginError { + /// The plugin is not currently able to produce or apply configuration. + #[error("plugin unavailable: {0}")] + Unavailable(String), + /// A peer announcement was not acceptable to the plugin. + #[error("rejected peer announcement: {0}")] + Rejected(String), + /// Anything else. + #[error("plugin error: {0}")] + Other(String), +} + +/// The minimal contract a future IP plugin implements. +/// +/// Implementations must be cheap and non-blocking: the agent calls them from +/// its runtime tasks. Anything slow belongs in the plugin's own tasks. +pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static { + /// Stable protocol identifier, e.g. `wireguard`. + /// + /// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes. + fn protocol_id(&self) -> &str; + + /// Produces this agent's announcement for a given network. + /// + /// Returning `Ok(None)` means "nothing to announce right now", which is + /// different from an error. + fn local_capability( + &self, + network: NetworkId, + ) -> std::result::Result, PluginError>; + + /// Called when a peer announces a capability for this plugin's protocol. + /// + /// The core has already bounded the payload size but has not interpreted it. + fn on_peer_capability( + &self, + network: NetworkId, + peer: EndpointId, + capability: &PluginCapability, + ) -> std::result::Result<(), PluginError>; + + /// Called when a peer's session in a network goes away. + fn on_peer_gone(&self, network: NetworkId, peer: EndpointId); + + /// Called when a network is deactivated locally. + /// + /// This is a local deactivation, not a signed revocation of membership. + fn on_network_deactivated(&self, network: NetworkId); +} + +/// A shared handle to a plugin. +pub type SharedPlugin = Arc; + +/// A plugin used in tests and examples. +/// +/// It announces an explicitly test-only protocol id, so nothing in this crate +/// ever advertises WireGuard as an available transport before it exists. +#[derive(Debug)] +pub struct TestCapabilityPlugin { + protocol: String, + payload: Vec, + seen: std::sync::Mutex>, +} + +impl TestCapabilityPlugin { + /// Creates a plugin announcing `protocol` with a fixed opaque payload. + pub fn new(protocol: impl Into, payload: impl Into>) -> Self { + Self { + protocol: protocol.into(), + payload: payload.into(), + seen: std::sync::Mutex::new(Vec::new()), + } + } + + /// Returns everything this plugin was handed so far. + pub fn observed(&self) -> Vec<(NetworkId, EndpointId, PluginCapability)> { + match self.seen.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } +} + +impl IpPlugin for TestCapabilityPlugin { + fn protocol_id(&self) -> &str { + &self.protocol + } + + fn local_capability( + &self, + _network: NetworkId, + ) -> std::result::Result, PluginError> { + Ok(Some(PluginCapability { + protocol: self.protocol.clone(), + version: 1, + enabled: true, + data: self.payload.clone(), + })) + } + + fn on_peer_capability( + &self, + network: NetworkId, + peer: EndpointId, + capability: &PluginCapability, + ) -> std::result::Result<(), PluginError> { + let mut guard = match self.seen.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + guard.push((network, peer, capability.clone())); + Ok(()) + } + + fn on_peer_gone(&self, _network: NetworkId, _peer: EndpointId) {} + + fn on_network_deactivated(&self, _network: NetworkId) {} +} diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..ea3d3b6 --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,303 @@ +//! Finding *candidates*, and nothing more. +//! +//! Discovery answers one question: "which iroh endpoints might currently be +//! participating in the network behind this [`DiscoveryKey`], and at which +//! addresses?". Its answers are **unverified candidates**. Membership is decided +//! later, by the control protocol handshake in [`crate::proto::handshake`]. +//! +//! A discovery backend must not carry control messages between agents, must not +//! confirm authentication and must not mutate agent state directly. +//! +//! Two concerns are kept apart: +//! +//! * *Finding members of a network* — [`NetworkDiscovery::resolve`], keyed by +//! the secret-derived [`DiscoveryKey`]. +//! * *Resolving the address of one iroh endpoint* — an +//! [`iroh::EndpointAddr`] either already carries addresses, or iroh's own +//! address lookup service must be enabled. Dialling a bare [`EndpointId`] +//! with neither is expected to fail. +//! +//! No empty result ever proves a network is empty. It only means "nobody found +//! yet". +//! +//! Mainline DHT discovery is future work and is not implemented here. + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; + +use iroh::{EndpointAddr, EndpointId}; + +use crate::error::Result; +use crate::identity::DiscoveryKey; + +/// A boxed future, so that [`NetworkDiscovery`] stays object safe. +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Where a candidate came from. Purely informational. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CandidateSource { + /// A statically configured bootstrap entry. + Bootstrap, + /// A discovery backend lookup. + Discovery, + /// An address hint restored from the disposable cache. + Cache, +} + +/// An unverified candidate peer. +/// +/// Holding one grants nothing: the peer still has to pass the handshake. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// iroh address of the candidate, including whatever addressing info exists. + pub addr: EndpointAddr, + /// Where this candidate came from. + pub source: CandidateSource, +} + +impl Candidate { + /// Creates a candidate. + pub fn new(addr: EndpointAddr, source: CandidateSource) -> Self { + Self { addr, source } + } + + /// The candidate's endpoint id. + pub fn endpoint_id(&self) -> EndpointId { + self.addr.id + } +} + +/// A replaceable source of candidates. +/// +/// Implementations must be cheap to clone behind an [`Arc`] and must never +/// block the async executor. +pub trait NetworkDiscovery: Send + Sync + std::fmt::Debug + 'static { + /// A short name used in diagnostics. + fn name(&self) -> &str; + + /// Publishes this agent's address under `key`. + /// + /// Backends that cannot publish (static bootstrap lists) return `Ok(())`. + fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>>; + + /// Withdraws a previously published address. + fn unpublish<'a>( + &'a self, + key: DiscoveryKey, + endpoint: EndpointId, + ) -> BoxFuture<'a, Result<()>>; + + /// Returns the candidates currently known for `key`. + fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result>>; +} + +/// A statically configured list of bootstrap candidates. +/// +/// Each entry must carry enough addressing information to be dialled, i.e. an +/// iroh endpoint id plus direct addresses or a relay URL, unless iroh's own +/// address lookup is enabled in [`crate::config::TransportPolicy`]. +#[derive(Debug, Clone, Default)] +pub struct StaticBootstrap { + entries: Vec, +} + +impl StaticBootstrap { + /// Creates a bootstrap list. + pub fn new(entries: impl IntoIterator) -> Self { + Self { + entries: entries.into_iter().collect(), + } + } +} + +impl NetworkDiscovery for StaticBootstrap { + fn name(&self) -> &str { + "static-bootstrap" + } + + fn publish<'a>(&'a self, _key: DiscoveryKey, _addr: EndpointAddr) -> BoxFuture<'a, Result<()>> { + Box::pin(async { Ok(()) }) + } + + fn unpublish<'a>( + &'a self, + _key: DiscoveryKey, + _endpoint: EndpointId, + ) -> BoxFuture<'a, Result<()>> { + Box::pin(async { Ok(()) }) + } + + fn resolve<'a>(&'a self, _key: DiscoveryKey) -> BoxFuture<'a, Result>> { + let candidates: Vec = self + .entries + .iter() + .cloned() + .map(|addr| Candidate::new(addr, CandidateSource::Bootstrap)) + .collect(); + Box::pin(async move { Ok(candidates) }) + } +} + +/// An in-process discovery backend used by tests and examples. +/// +/// It stores a mapping from [`DiscoveryKey`] to endpoint addresses and nothing +/// else. It carries no messages, performs no authentication and cannot touch an +/// agent's state. Clone it to hand the same rendezvous table to several agents; +/// create a new one per test so that tests stay independent — there is no global +/// mutable state here. +#[derive(Debug, Clone, Default)] +pub struct SharedMemoryDiscovery { + inner: Arc>>>, +} + +impl SharedMemoryDiscovery { + /// Creates an empty rendezvous table. + pub fn new() -> Self { + Self::default() + } + + /// Number of entries published under `key`. Useful in tests. + pub fn len(&self, key: &DiscoveryKey) -> usize { + self.with_inner(|map| map.get(key).map_or(0, HashMap::len)) + } + + /// Whether nothing is published under `key`. + pub fn is_empty(&self, key: &DiscoveryKey) -> bool { + self.len(key) == 0 + } + + /// Removes every entry under `key`, simulating a discovery outage. + pub fn clear(&self, key: &DiscoveryKey) { + self.with_inner(|map| { + map.remove(key); + }); + } + + /// Replaces an entry with a deliberately wrong address, simulating a stale + /// or poisoned record. + pub fn insert_raw(&self, key: DiscoveryKey, addr: EndpointAddr) { + self.with_inner(|map| { + map.entry(key).or_default().insert(addr.id, addr); + }); + } + + fn with_inner( + &self, + f: impl FnOnce(&mut HashMap>) -> T, + ) -> T { + let mut guard = match self.inner.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + f(&mut guard) + } +} + +impl NetworkDiscovery for SharedMemoryDiscovery { + fn name(&self) -> &str { + "shared-memory" + } + + fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> { + self.with_inner(|map| { + map.entry(key).or_default().insert(addr.id, addr); + }); + Box::pin(async { Ok(()) }) + } + + fn unpublish<'a>( + &'a self, + key: DiscoveryKey, + endpoint: EndpointId, + ) -> BoxFuture<'a, Result<()>> { + self.with_inner(|map| { + if let Some(entries) = map.get_mut(&key) { + entries.remove(&endpoint); + if entries.is_empty() { + map.remove(&key); + } + } + }); + Box::pin(async { Ok(()) }) + } + + fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result>> { + let candidates: Vec = self.with_inner(|map| { + map.get(&key) + .map(|entries| { + entries + .values() + .cloned() + .map(|addr| Candidate::new(addr, CandidateSource::Discovery)) + .collect() + }) + .unwrap_or_default() + }); + Box::pin(async move { Ok(candidates) }) + } +} + +/// Combines several backends, concatenating their candidates. +#[derive(Debug, Clone)] +pub struct CompositeDiscovery { + backends: Vec>, +} + +impl CompositeDiscovery { + /// Creates a composite over the given backends. + pub fn new(backends: impl IntoIterator>) -> Self { + Self { + backends: backends.into_iter().collect(), + } + } +} + +impl NetworkDiscovery for CompositeDiscovery { + fn name(&self) -> &str { + "composite" + } + + fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + for backend in &self.backends { + // One failing backend must not stop the others. + if let Err(err) = backend.publish(key, addr.clone()).await { + tracing::debug!(backend = backend.name(), %err, "publish failed"); + } + } + Ok(()) + }) + } + + fn unpublish<'a>( + &'a self, + key: DiscoveryKey, + endpoint: EndpointId, + ) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + for backend in &self.backends { + if let Err(err) = backend.unpublish(key, endpoint).await { + tracing::debug!(backend = backend.name(), %err, "unpublish failed"); + } + } + Ok(()) + }) + } + + fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result>> { + Box::pin(async move { + let mut out = Vec::new(); + for backend in &self.backends { + match backend.resolve(key).await { + Ok(mut found) => out.append(&mut found), + Err(err) => { + tracing::debug!(backend = backend.name(), %err, "resolve failed"); + } + } + } + Ok(out) + }) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..f212097 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,191 @@ +//! Error types for the whole library. +//! +//! The library never panics on untrusted network input: every decoding and +//! validation failure is represented as a [`ProtocolError`] and surfaced as a +//! rejected message or session, never as a process abort. + +use std::path::PathBuf; + +use crate::identity::NetworkId; + +/// Convenient result alias used across the crate. +pub type Result = std::result::Result; + +/// Top level error type of the library. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The supplied network name does not satisfy the documented rules. + #[error("invalid network name: {0}")] + InvalidNetworkName(&'static str), + + /// The supplied network secret does not satisfy the documented rules. + /// + /// The secret itself is never included in the message. + #[error("invalid network secret: {0}")] + InvalidNetworkSecret(&'static str), + + /// A textual identifier could not be parsed. + #[error("invalid {kind}: {reason}")] + InvalidEncoding { + /// What was being parsed, e.g. `network id`. + kind: &'static str, + /// Why parsing failed. + reason: &'static str, + }, + + /// The mandatory state directory is already owned by another live agent. + /// + /// This is an ownership lock, not a "file exists" check. + #[error("state directory {path} is owned by another running agent instance")] + StateLocked { + /// Directory that could not be locked. + path: PathBuf, + }, + + /// The mandatory state store is unusable. It is never silently recreated. + #[error("mandatory state store at {path} is unusable and was NOT reset: {reason}")] + StateCorrupted { + /// Path of the unusable store. + path: PathBuf, + /// Human readable reason, free of secrets. + reason: String, + }, + + /// The mandatory state store has a schema this build cannot handle. + #[error( + "state store schema version {found} is not supported by this build (supported: {supported})" + )] + UnsupportedSchema { + /// Version found on disk. + found: i64, + /// Version this build writes. + supported: i64, + }, + + /// A storage operation failed. + #[error("storage error: {0}")] + Storage(String), + + /// A filesystem operation failed. + #[error("io error at {path}: {source}")] + Io { + /// Path involved in the failure. + path: PathBuf, + /// Underlying error. + #[source] + source: std::io::Error, + }, + + /// Binding or driving the iroh endpoint failed. + #[error("iroh endpoint error: {0}")] + Endpoint(String), + + /// A control protocol violation. + #[error(transparent)] + Protocol(#[from] ProtocolError), + + /// The requested network is not currently active on this agent. + #[error("network {0} is not active")] + NetworkNotActive(NetworkId), + + /// The requested network is already active on this agent. + #[error("network {0} is already active")] + NetworkAlreadyActive(NetworkId), + + /// The requested network is not configured in the state store. + #[error("network {0} is not configured")] + NetworkUnknown(NetworkId), + + /// No session with that peer exists in the given network. + #[error("no authenticated session with peer {peer} in network {network}")] + NoSuchPeer { + /// Network the lookup was scoped to. + network: NetworkId, + /// Peer that was looked up, short form. + peer: String, + }, + + /// The agent is shutting down or already stopped. + #[error("agent is stopped")] + Stopped, + + /// Discovery backend failure. Never fatal for the agent. + #[error("discovery error: {0}")] + Discovery(String), +} + +/// Errors produced while speaking the control protocol. +/// +/// These always result in rejecting a single message or a single session. They +/// never stop other networks, other peers, or the agent itself. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ProtocolError { + /// The peer announced an unsupported control protocol version. + #[error("unsupported control protocol version {found} (this build speaks {supported})")] + UnsupportedVersion { + /// Version announced by the peer. + found: u16, + /// Version this build speaks. + supported: u16, + }, + + /// A frame header announced more bytes than the configured limit allows. + /// + /// Checked *before* any buffer of that size is allocated. + #[error("frame of {announced} bytes exceeds the {limit} byte limit")] + FrameTooLarge { + /// Length announced in the frame header. + announced: u64, + /// Configured limit. + limit: usize, + }, + + /// A frame could not be decoded. + #[error("malformed frame: {0}")] + Malformed(&'static str), + + /// The stream ended before a complete frame was read. + #[error("stream closed while reading a frame")] + StreamClosed, + + /// An underlying stream read or write failed. + #[error("stream error: {0}")] + Stream(String), + + /// The peer failed to prove knowledge of the derived network secret. + #[error("network authentication failed")] + AuthenticationFailed, + + /// The peer asked for a network this agent does not have active. + #[error("peer requested an unknown or inactive network")] + UnknownNetwork, + + /// A message carried a network id different from the session's network. + #[error("message network id does not match the authenticated session network")] + NetworkMismatch, + + /// A regular control message arrived before the handshake completed. + #[error("control message received before authentication completed")] + NotAuthenticated, + + /// A handshake step did not complete within the configured timeout. + #[error("handshake timed out")] + HandshakeTimeout, + + /// A field exceeded its configured bound. + #[error("field `{field}` exceeds its limit ({len} > {limit})")] + FieldTooLarge { + /// Name of the offending field. + field: &'static str, + /// Observed length. + len: usize, + /// Configured limit. + limit: usize, + }, + + /// The connection is not usable for deriving channel binding material. + #[error("connection does not provide TLS exporter material: {0}")] + NoChannelBinding(String), +} diff --git a/src/identity/mod.rs b/src/identity/mod.rs new file mode 100644 index 0000000..c1ad0b9 --- /dev/null +++ b/src/identity/mod.rs @@ -0,0 +1,70 @@ +//! Device identity and network space identity. +//! +//! These are two independent things and must not be confused: +//! +//! * [`DeviceIdentity`] wraps the persistent iroh [`SecretKey`]. Its public key +//! *is* the iroh [`EndpointId`]. It survives restarts and survives a change of +//! the network secret. +//! * [`NetworkId`] identifies a network space and is derived purely from the +//! network name and shared secret. It is unrelated to any device key. + +mod network; + +pub use network::{ + DiscoveryKey, IDENTITY_SCHEME, MAX_NETWORK_NAME_LEN, MAX_NETWORK_SECRET_LEN, + MIN_NETWORK_SECRET_LEN, NetworkDescriptor, NetworkId, NetworkKeys, NetworkName, NetworkSecret, + SECRET_TEXT_PREFIX, +}; + +use iroh::{EndpointId, SecretKey}; + +/// The persistent identity of this device. +/// +/// Created once and stored in the mandatory state store. Restarting the agent +/// must not produce a new peer, so the stored secret key is always reused. +/// Corruption of the stored key is reported as an error and never silently +/// replaced by a fresh key. +#[derive(Clone)] +pub struct DeviceIdentity { + secret: SecretKey, +} + +impl DeviceIdentity { + /// Generates a brand new device identity. + pub fn generate() -> Self { + Self { + secret: SecretKey::generate(), + } + } + + /// Reconstructs a device identity from its stored 32 secret key bytes. + pub fn from_secret_bytes(bytes: &[u8; 32]) -> Self { + Self { + secret: SecretKey::from_bytes(bytes), + } + } + + /// The iroh endpoint id, i.e. the public key of this device. + pub fn endpoint_id(&self) -> EndpointId { + self.secret.public() + } + + /// The raw secret key bytes, for persistence only. + pub(crate) fn secret_bytes(&self) -> [u8; 32] { + self.secret.to_bytes() + } + + /// A clone of the iroh secret key, for endpoint construction only. + pub(crate) fn secret_key(&self) -> SecretKey { + self.secret.clone() + } +} + +impl std::fmt::Debug for DeviceIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DeviceIdentity") + .field("endpoint_id", &self.endpoint_id().fmt_short().to_string()) + .field("secret", &"") + .finish() + } +} diff --git a/src/identity/network.rs b/src/identity/network.rs new file mode 100644 index 0000000..e873858 --- /dev/null +++ b/src/identity/network.rs @@ -0,0 +1,419 @@ +//! Deterministic network space identity. +//! +//! A *network space* is fully determined by a [`NetworkName`] and a +//! [`NetworkSecret`]. Two agents that were given the same pair derive the same +//! [`NetworkId`], [`DiscoveryKey`] and handshake authentication key, with no +//! coordination, no creator, no timestamp and no leader election. +//! +//! # Derivation scheme (`tsunagi-network-id-v1`) +//! +//! All inputs are encoded with an unambiguous length-prefixed encoding, written +//! here as `LP(x) = u32_be(x.len()) || x`. String concatenation is never used. +//! +//! ```text +//! salt = SHA-256( LP("tsunagi-network-id-v1") || LP(name_utf8) ) +//! prk = HKDF-SHA256-Extract(salt, ikm = secret_bytes) +//! info(label) = LP("tsunagi-network-id-v1") || LP(label) +//! network_id = HKDF-Expand(prk, info("network-id"), 32) +//! discovery_key = HKDF-Expand(prk, info("discovery-key"), 32) +//! auth_key = HKDF-Expand(prk, info("handshake-auth"), 32) +//! ``` +//! +//! HKDF's `info` parameter is what separates the three derived values +//! (RFC 5869 §3.2). Learning `discovery_key` — which is published to a +//! discovery backend and is therefore semi-public — does not reveal `auth_key`, +//! so the discovery key must never be used as a password or bearer token. +//! +//! The scheme label is versioned and frozen. Upgrading this crate or bumping +//! the control protocol version must not change an existing [`NetworkId`]. + +use hkdf::Hkdf; +use sha2::{Digest, Sha256}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::error::{Error, Result}; + +/// Frozen label identifying the network identity derivation scheme. +/// +/// Changing this string creates a different, incompatible network space for the +/// same name and secret. It must never be changed casually. +pub const IDENTITY_SCHEME: &str = "tsunagi-network-id-v1"; + +/// Maximum length of a network name in UTF-8 bytes. +pub const MAX_NETWORK_NAME_LEN: usize = 64; + +/// Minimum length of a network secret in bytes. +/// +/// The proof-of-concept targets high-entropy shared secrets. See +/// [`NetworkSecret::generate`]. +pub const MIN_NETWORK_SECRET_LEN: usize = 16; + +/// Maximum length of a network secret in bytes. +pub const MAX_NETWORK_SECRET_LEN: usize = 1024; + +/// Human-readable prefix of the canonical secret text encoding. +pub const SECRET_TEXT_PREFIX: &str = "tsn1"; + +/// Appends `LP(bytes) = u32_be(len) || bytes` to `out`. +/// +/// Panics are impossible here: the caller-provided slices are already bounded by +/// [`MAX_NETWORK_NAME_LEN`] / [`MAX_NETWORK_SECRET_LEN`], and the cast is +/// saturating for anything larger. +fn push_lp(out: &mut Vec, bytes: &[u8]) { + let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(bytes); +} + +/// The name half of a network space. +/// +/// Rules, deliberately strict and never silently applied: +/// +/// * 1..=[`MAX_NETWORK_NAME_LEN`] bytes of UTF-8. +/// * No ASCII control characters. +/// * No leading or trailing ASCII whitespace — such a name is **rejected**, +/// not trimmed. +/// * Used verbatim. No case folding and no Unicode normalisation is performed, +/// so `Home` and `home` are different network spaces. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NetworkName(String); + +impl NetworkName { + /// Validates and wraps a network name. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() { + return Err(Error::InvalidNetworkName("must not be empty")); + } + if name.len() > MAX_NETWORK_NAME_LEN { + return Err(Error::InvalidNetworkName( + "must not exceed 64 bytes of UTF-8", + )); + } + if name.chars().any(|c| c.is_control()) { + return Err(Error::InvalidNetworkName( + "must not contain control characters", + )); + } + let trimmed = name.trim_matches(|c: char| c.is_ascii_whitespace()); + if trimmed.len() != name.len() { + return Err(Error::InvalidNetworkName( + "must not have leading or trailing ASCII whitespace", + )); + } + Ok(Self(name)) + } + + /// Returns the name as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for NetworkName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::str::FromStr for NetworkName { + type Err = Error; + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +/// The shared secret half of a network space. +/// +/// This is the single shared secret the end user configures; "password" and +/// "secret" refer to the same value. The bytes are used **verbatim**: never +/// trimmed, case-folded, normalised or truncated. +/// +/// The value is zeroized on drop and redacted from [`Debug`]. +#[derive(Clone, PartialEq, Eq)] +pub struct NetworkSecret(Zeroizing>); + +impl NetworkSecret { + /// Wraps raw secret bytes. + /// + /// Requires at least [`MIN_NETWORK_SECRET_LEN`] bytes. This crate makes no + /// security promises for short, low-entropy human passphrases: there is no + /// PAKE here, so an offline guessing attack against a weak secret is cheap + /// for anyone who can reach the handshake. + pub fn from_bytes(bytes: impl Into>) -> Result { + let bytes = Zeroizing::new(bytes.into()); + if bytes.len() < MIN_NETWORK_SECRET_LEN { + return Err(Error::InvalidNetworkSecret( + "must be at least 16 bytes; use NetworkSecret::generate()", + )); + } + if bytes.len() > MAX_NETWORK_SECRET_LEN { + return Err(Error::InvalidNetworkSecret("must not exceed 1024 bytes")); + } + Ok(Self(bytes)) + } + + /// Generates a fresh 32-byte random secret. + /// + /// This is the recommended way to create a network secret. + pub fn generate() -> Self { + let mut buf = vec![0u8; 32]; + rand::fill(&mut buf[..]); + Self(Zeroizing::new(buf)) + } + + /// Parses the canonical text form produced by [`NetworkSecret::encode`]. + /// + /// The format is `tsn1` followed by lowercase unpadded RFC 4648 base32. + /// Parsing is strict: no whitespace, no case mixing in the payload. + pub fn decode(text: &str) -> Result { + let payload = text + .strip_prefix(SECRET_TEXT_PREFIX) + .ok_or(Error::InvalidNetworkSecret( + "canonical secrets start with `tsn1`", + ))?; + let bytes = data_encoding::BASE32_NOPAD + .decode(payload.to_ascii_uppercase().as_bytes()) + .map_err(|_| Error::InvalidNetworkSecret("not valid base32"))?; + Self::from_bytes(bytes) + } + + /// Encodes the secret in its canonical text form. + /// + /// The returned string is zeroized on drop. Never log it. + pub fn encode(&self) -> Zeroizing { + let mut encoded = data_encoding::BASE32_NOPAD.encode(&self.0); + encoded.make_ascii_lowercase(); + let out = Zeroizing::new(format!("{SECRET_TEXT_PREFIX}{encoded}")); + encoded.zeroize(); + out + } + + /// Exposes the raw secret bytes. + /// + /// Callers must not log, serialise or copy these bytes into diagnostics. + pub(crate) fn expose(&self) -> &[u8] { + &self.0 + } +} + +impl std::fmt::Debug for NetworkSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("NetworkSecret()") + } +} + +/// Encodes 32 bytes as lowercase unpadded base32. +fn b32(bytes: &[u8; 32]) -> String { + let mut s = data_encoding::BASE32_NOPAD.encode(bytes); + s.make_ascii_lowercase(); + s +} + +/// Decodes lowercase unpadded base32 into 32 bytes. +fn unb32(kind: &'static str, s: &str) -> Result<[u8; 32]> { + let bytes = data_encoding::BASE32_NOPAD + .decode(s.to_ascii_uppercase().as_bytes()) + .map_err(|_| Error::InvalidEncoding { + kind, + reason: "not valid base32", + })?; + <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| Error::InvalidEncoding { + kind, + reason: "expected 32 bytes", + }) +} + +/// Public, non-secret identifier of a network space. +/// +/// Safe to log, publish and put into status output. It does not authorise +/// anything on its own: an attacker who knows a `NetworkId` still cannot pass +/// the handshake without the secret. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NetworkId([u8; 32]); + +impl NetworkId { + /// Returns the raw 32 bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Builds a network id from raw bytes. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Returns a short prefix useful for logs. + pub fn fmt_short(&self) -> String { + b32(&self.0).chars().take(10).collect() + } +} + +impl std::fmt::Display for NetworkId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&b32(&self.0)) + } +} + +impl std::fmt::Debug for NetworkId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "NetworkId({})", self.fmt_short()) + } +} + +impl std::str::FromStr for NetworkId { + type Err = Error; + fn from_str(s: &str) -> Result { + unb32("network id", s).map(Self) + } +} + +/// Lookup key used to find candidates for a network in a discovery backend. +/// +/// Derived from the secret, so it is not published in the clear the way a +/// [`NetworkId`] is. It is nevertheless **not** a credential: a discovery +/// backend, or anyone observing it, learns nothing that helps pass the +/// handshake. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DiscoveryKey([u8; 32]); + +impl DiscoveryKey { + /// Returns the raw 32 bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Builds a discovery key from raw bytes. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl std::fmt::Display for DiscoveryKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&b32(&self.0)) + } +} + +impl std::fmt::Debug for DiscoveryKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "DiscoveryKey({}…)", + b32(&self.0).chars().take(10).collect::() + ) + } +} + +/// The deterministic, immutable description of a network space. +/// +/// There is no competing genesis: this value contains no creator identity, no +/// creation time and no owner signature, so two agents started independently +/// with the same parameters produce byte-identical descriptors. The secret is +/// never part of it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkDescriptor { + /// Frozen derivation scheme label, see [`IDENTITY_SCHEME`]. + pub scheme: &'static str, + /// The network name. + pub name: NetworkName, + /// The derived public network identifier. + pub network_id: NetworkId, +} + +impl NetworkDescriptor { + /// Canonical, unambiguous byte encoding of the descriptor. + pub fn to_canonical_bytes(&self) -> Vec { + let mut out = Vec::new(); + push_lp(&mut out, self.scheme.as_bytes()); + push_lp(&mut out, self.name.as_str().as_bytes()); + push_lp(&mut out, &self.network_id.0); + out + } +} + +/// All key material derived from a [`NetworkName`] and [`NetworkSecret`]. +/// +/// The authentication key is zeroized on drop and never leaves this crate. +#[derive(Clone)] +pub struct NetworkKeys { + network_id: NetworkId, + discovery_key: DiscoveryKey, + auth_key: Zeroizing<[u8; 32]>, + name: NetworkName, +} + +impl NetworkKeys { + /// Derives all network key material. + /// + /// This is a pure function of `(name, secret)`. It does not depend on the + /// device key, the hostname, the wall clock or the order in which agents + /// start. + pub fn derive(name: &NetworkName, secret: &NetworkSecret) -> Self { + let mut salt_input = Vec::new(); + push_lp(&mut salt_input, IDENTITY_SCHEME.as_bytes()); + push_lp(&mut salt_input, name.as_str().as_bytes()); + let salt = Sha256::digest(&salt_input); + + let hk = Hkdf::::new(Some(&salt), secret.expose()); + + let expand = |label: &str| -> [u8; 32] { + let mut info = Vec::new(); + push_lp(&mut info, IDENTITY_SCHEME.as_bytes()); + push_lp(&mut info, label.as_bytes()); + let mut okm = [0u8; 32]; + // 32 bytes is far below HKDF-SHA256's 255*32 limit, so this cannot fail. + match hk.expand(&info, &mut okm) { + Ok(()) => okm, + Err(_) => unreachable!("HKDF-SHA256 expand of 32 bytes cannot fail"), + } + }; + + Self { + network_id: NetworkId(expand("network-id")), + discovery_key: DiscoveryKey(expand("discovery-key")), + auth_key: Zeroizing::new(expand("handshake-auth")), + name: name.clone(), + } + } + + /// The public network identifier. + pub fn network_id(&self) -> NetworkId { + self.network_id + } + + /// The discovery lookup key. + pub fn discovery_key(&self) -> DiscoveryKey { + self.discovery_key + } + + /// The network name. + pub fn name(&self) -> &NetworkName { + &self.name + } + + /// The immutable descriptor of this network space. + pub fn descriptor(&self) -> NetworkDescriptor { + NetworkDescriptor { + scheme: IDENTITY_SCHEME, + name: self.name.clone(), + network_id: self.network_id, + } + } + + /// The handshake authentication key. Crate-internal on purpose. + pub(crate) fn auth_key(&self) -> &[u8; 32] { + &self.auth_key + } +} + +impl std::fmt::Debug for NetworkKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NetworkKeys") + .field("name", &self.name) + .field("network_id", &self.network_id) + .field("discovery_key", &self.discovery_key) + .field("auth_key", &"") + .finish() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..56a2df9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,105 @@ +//! Tsunagi: a proof-of-concept agent for small private mesh networks. +//! +//! See `README.md` for the exact scope of this proof of concept, and +//! `docs/architecture.md` / `docs/protocol.md` for the design. +//! +//! # Shape of the library +//! +//! * [`identity`] — persistent device identity and deterministic network space +//! identity. +//! * [`storage`] — mandatory state (`state.sqlite`) and separately disposable +//! cache (`cache.sqlite`). +//! * [`discovery`] — pluggable sources of *candidate* addresses. Candidates are +//! never trusted peers. +//! * [`proto`] — the control protocol: framing, messages, handshake. +//! * [`net`] — the iroh connectivity adapter and its observability surface. +//! * [`agent`] — the runtime: agent lifecycle, per-network runtimes, reconnect. +//! * [`dataplane`] — the minimal contract future IP plugins must satisfy. +//! +//! # What this library deliberately does not do +//! +//! It never starts a global tokio runtime, never installs a global tracing +//! subscriber, never handles process signals, never forks and never calls +//! `process::exit`. Several independent agents can run in one process. +//! +//! Only control messages travel over iroh. User IP traffic is not tunnelled +//! through it. + +#![deny(rustdoc::broken_intra_doc_links)] + +pub mod agent; +pub mod config; +pub mod dataplane; +pub mod discovery; +pub mod error; +pub mod identity; +pub mod net; +pub mod proto; +pub mod storage; + +pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus}; +pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy}; +pub use error::{Error, ProtocolError, Result}; +pub use identity::{ + DeviceIdentity, DiscoveryKey, NetworkDescriptor, NetworkId, NetworkName, NetworkSecret, +}; + +/// Re-exported iroh types that appear in this crate's public API. +pub mod iroh_types { + pub use iroh::{EndpointAddr, EndpointId, RelayUrl}; +} + +#[doc(hidden)] +pub mod test_support { + //! Internals exposed for this crate's own negative tests. + //! + //! **Not part of the stable API.** It exists so the integration tests can + //! hand-craft handshakes — a valid proof to replay on another connection, a + //! message sent before authentication, an oversized frame — which is the + //! only way to test those rejections against a real agent. + + use iroh::endpoint::Connection; + + use crate::error::ProtocolError; + use crate::identity::NetworkKeys; + use crate::proto::handshake; + + /// Exposes the derived handshake authentication key. + pub fn auth_key(keys: &NetworkKeys) -> [u8; 32] { + *keys.auth_key() + } + + /// Derives this connection's channel binding material. + pub fn channel_binding( + conn: &Connection, + network_id: &[u8; 32], + ) -> Result<[u8; 32], ProtocolError> { + handshake::channel_binding_for_test(conn, network_id) + } + + /// Computes a handshake proof for the given role. + #[allow(clippy::too_many_arguments)] + pub fn compute_proof( + auth_key: &[u8; 32], + role: &str, + version: u16, + network_id: &[u8; 32], + initiator: &[u8; 32], + responder: &[u8; 32], + channel_binding: &[u8], + nonce_initiator: &[u8; 16], + nonce_responder: &[u8; 16], + ) -> [u8; 32] { + handshake::proof_for_test( + auth_key, + role, + version, + network_id, + initiator, + responder, + channel_binding, + nonce_initiator, + nonce_responder, + ) + } +} diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..2ac444a --- /dev/null +++ b/src/net.rs @@ -0,0 +1,304 @@ +//! The iroh connectivity adapter and its observability surface. +//! +//! This module builds and owns the [`Endpoint`], and turns iroh's runtime +//! information into plain owned snapshots that the rest of the library and the +//! library's users can read. +//! +//! # Honest reporting +//! +//! Three different things are kept distinct and never conflated: +//! +//! * an **unverified candidate** — something discovery handed us +//! ([`crate::discovery::Candidate`]); +//! * an **observed address** — an address this endpoint believes it has +//! ([`EndpointSnapshot::observed_addrs`]); +//! * a **verified path** — a network path QUIC has actually validated and is +//! using or can use ([`PathInfo`]). +//! +//! A value that is not available is reported as `None`. It is never invented. +//! +//! An iroh address is an address for *iroh*. It must not be assumed to be usable +//! by any other protocol; a future WireGuard plugin is expected to collect its +//! own reachability data. + +use std::net::SocketAddr; +use std::time::Duration; + +use iroh::endpoint::{ + Connection, ConnectionStats, PortmapperConfig, RecvStream, SendStream, presets, +}; +use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode}; + +use crate::config::{AgentConfig, TransportPolicy}; +use crate::error::{Error, Result}; +use crate::identity::DeviceIdentity; +use crate::proto::message::ALPN; + +/// A network path address as reported by iroh. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathAddr { + /// A direct IP path. + Ip(SocketAddr), + /// A path through a relay server. + Relay(String), + /// A custom transport iroh reported but this crate does not model. + Other(String), +} + +/// One verified network path of a connection. +#[derive(Debug, Clone)] +pub struct PathInfo { + /// Remote address of the path. + pub remote: PathAddr, + /// Local address of the path, when the OS reports one. + pub local: Option, + /// Whether QUIC currently transmits application data over this path. + pub is_selected: bool, + /// Round-trip time estimate for this path. + pub rtt: Duration, +} + +impl PathInfo { + /// Whether this is a direct IP path. + pub fn is_direct(&self) -> bool { + matches!(self.remote, PathAddr::Ip(_)) + } + + /// Whether this path goes through a relay. + pub fn is_relay(&self) -> bool { + matches!(self.remote, PathAddr::Relay(_)) + } +} + +/// How a connection currently reaches its peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportKind { + /// A direct IP path is selected. + Direct, + /// A relay path is selected. + Relay, + /// iroh has not reported a selected path yet. + Unknown, +} + +/// Counters for one connection. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConnectionCounters { + /// UDP bytes sent on this connection. + pub udp_tx_bytes: u64, + /// UDP bytes received on this connection. + pub udp_rx_bytes: u64, + /// UDP datagrams sent. + pub udp_tx_datagrams: u64, + /// UDP datagrams received. + pub udp_rx_datagrams: u64, + /// Packets declared lost. + pub lost_packets: u64, +} + +impl From for ConnectionCounters { + fn from(stats: ConnectionStats) -> Self { + Self { + udp_tx_bytes: stats.udp_tx.bytes, + udp_rx_bytes: stats.udp_rx.bytes, + udp_tx_datagrams: stats.udp_tx.datagrams, + udp_rx_datagrams: stats.udp_rx.datagrams, + lost_packets: stats.lost_packets, + } + } +} + +/// An owned snapshot of one live connection. +#[derive(Debug, Clone)] +pub struct ConnectionSnapshot { + /// Authenticated endpoint id of the remote side. + pub remote_id: EndpointId, + /// Verified paths, as reported by iroh at snapshot time. + pub paths: Vec, + /// How the connection currently reaches the peer. + pub transport: TransportKind, + /// RTT of the selected path, when there is one. + pub rtt: Option, + /// Per-connection counters. + pub counters: ConnectionCounters, +} + +/// Snapshot of this endpoint, independent of any particular network. +#[derive(Debug, Clone)] +pub struct EndpointSnapshot { + /// This endpoint's id. + pub endpoint_id: EndpointId, + /// Sockets actually bound locally. + pub bound_sockets: Vec, + /// Addresses iroh believes this endpoint is reachable at. + /// + /// These are *observed*, not verified by any remote peer. + pub observed_addrs: Vec, + /// Relay URLs this endpoint currently considers usable, if any. + pub relay_urls: Vec, +} + +fn path_addr(addr: &iroh::TransportAddr) -> PathAddr { + match addr { + iroh::TransportAddr::Ip(socket) => PathAddr::Ip(*socket), + iroh::TransportAddr::Relay(url) => PathAddr::Relay(url.to_string()), + other => PathAddr::Other(format!("{other:?}")), + } +} + +/// Builds an owned snapshot of a live connection. +pub fn snapshot_connection(conn: &Connection) -> ConnectionSnapshot { + let mut paths = Vec::new(); + let mut transport = TransportKind::Unknown; + let mut rtt = None; + + for path in conn.paths().iter() { + let info = PathInfo { + remote: path_addr(path.remote_addr()), + local: local_addr_string(path.local_addr()), + is_selected: path.is_selected(), + rtt: path.rtt(), + }; + if info.is_selected { + transport = if info.is_relay() { + TransportKind::Relay + } else if info.is_direct() { + TransportKind::Direct + } else { + TransportKind::Unknown + }; + rtt = Some(info.rtt); + } + paths.push(info); + } + + ConnectionSnapshot { + remote_id: conn.remote_id(), + paths, + transport, + rtt, + counters: conn.stats().into(), + } +} + +fn local_addr_string(addr: &iroh::endpoint::LocalTransportAddr) -> Option { + match addr { + iroh::endpoint::LocalTransportAddr::Ip(Some(ip)) => Some(ip.to_string()), + iroh::endpoint::LocalTransportAddr::Ip(None) => None, + iroh::endpoint::LocalTransportAddr::Relay(url) => Some(url.to_string()), + iroh::endpoint::LocalTransportAddr::Custom(Some(custom)) => Some(format!("{custom:?}")), + iroh::endpoint::LocalTransportAddr::Custom(None) => None, + _ => None, + } +} + +/// Thin wrapper around the iroh endpoint. +#[derive(Debug, Clone)] +pub struct EndpointAdapter { + endpoint: Endpoint, +} + +impl EndpointAdapter { + /// Binds an endpoint according to `config`, reusing the persistent device key. + pub async fn bind(config: &AgentConfig, identity: &DeviceIdentity) -> Result { + let mut builder = Endpoint::builder(presets::Minimal) + .secret_key(identity.secret_key()) + .alpns(vec![ALPN.to_vec()]); + + builder = match config.transport { + TransportPolicy::LocalOnly => builder + .relay_mode(RelayMode::Disabled) + .clear_address_lookup() + .portmapper_config(PortmapperConfig::Disabled) + .net_report_config(iroh::NetReportConfig::minimal()), + TransportPolicy::DirectOnly => builder.preset(presets::N0DisableRelay), + TransportPolicy::N0Defaults => builder.preset(presets::N0), + }; + + if !config.bind_addrs.is_empty() { + builder = builder.clear_ip_transports(); + for addr in &config.bind_addrs { + builder = builder + .bind_addr(*addr) + .map_err(|err| Error::Endpoint(format!("invalid bind address: {err}")))?; + } + } + + let endpoint = builder + .bind() + .await + .map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?; + + Ok(Self { endpoint }) + } + + /// The underlying iroh endpoint. + pub fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + /// This endpoint's id. + pub fn endpoint_id(&self) -> EndpointId { + self.endpoint.id() + } + + /// This endpoint's dialable address, as currently known. + pub fn addr(&self) -> EndpointAddr { + self.endpoint.addr() + } + + /// Builds an address containing only the locally bound sockets. + /// + /// Useful when address lookup and relays are disabled and peers must be + /// given literal addresses. + pub fn loopback_addr(&self) -> EndpointAddr { + self.endpoint + .bound_sockets() + .into_iter() + .fold(EndpointAddr::new(self.endpoint.id()), |addr, socket| { + addr.with_ip_addr(socket) + }) + } + + /// Snapshot of endpoint-level information. + pub fn snapshot(&self) -> EndpointSnapshot { + let addr = self.endpoint.addr(); + let mut observed = Vec::new(); + let mut relays = Vec::new(); + for socket in addr.ip_addrs() { + observed.push(PathAddr::Ip(*socket)); + } + for url in addr.relay_urls() { + relays.push(url.to_string()); + observed.push(PathAddr::Relay(url.to_string())); + } + EndpointSnapshot { + endpoint_id: self.endpoint.id(), + bound_sockets: self.endpoint.bound_sockets(), + observed_addrs: observed, + relay_urls: relays, + } + } + + /// Dials a candidate and opens the control stream. + pub async fn connect( + &self, + addr: EndpointAddr, + ) -> Result<(Connection, SendStream, RecvStream), Error> { + let conn = self + .endpoint + .connect(addr, ALPN) + .await + .map_err(|err| Error::Endpoint(format!("connect failed: {err}")))?; + let (send, recv) = conn + .open_bi() + .await + .map_err(|err| Error::Endpoint(format!("cannot open control stream: {err}")))?; + Ok((conn, send, recv)) + } + + /// Closes the endpoint and waits for it to finish. + pub async fn close(&self) { + self.endpoint.close().await; + } +} diff --git a/src/proto/frame.rs b/src/proto/frame.rs new file mode 100644 index 0000000..0bb2224 --- /dev/null +++ b/src/proto/frame.rs @@ -0,0 +1,80 @@ +//! Length-prefixed framing over one QUIC bidirectional stream. +//! +//! A frame is `u32_be(len) || payload`. The announced length is validated +//! against the configured limit **before** a buffer of that size is allocated, +//! so a hostile peer cannot make the agent allocate arbitrary memory with a +//! four byte header. +//! +//! No custom encryption is layered on top: the iroh/QUIC connection already +//! provides confidentiality, integrity and endpoint authentication. + +use iroh::endpoint::{RecvStream, SendStream}; + +use crate::error::ProtocolError; + +/// Size of the frame length prefix, in bytes. +pub const LENGTH_PREFIX_LEN: usize = 4; + +/// Writes one frame. +pub async fn write_frame( + stream: &mut SendStream, + payload: &[u8], + max_frame_len: usize, +) -> Result<(), ProtocolError> { + if payload.len() > max_frame_len { + return Err(ProtocolError::FrameTooLarge { + announced: payload.len() as u64, + limit: max_frame_len, + }); + } + let len = u32::try_from(payload.len()).map_err(|_| ProtocolError::FrameTooLarge { + announced: payload.len() as u64, + limit: max_frame_len, + })?; + stream + .write_all(&len.to_be_bytes()) + .await + .map_err(|err| ProtocolError::Stream(err.to_string()))?; + stream + .write_all(payload) + .await + .map_err(|err| ProtocolError::Stream(err.to_string()))?; + Ok(()) +} + +/// Reads one frame, rejecting oversized headers before allocating. +pub async fn read_frame( + stream: &mut RecvStream, + max_frame_len: usize, +) -> Result, ProtocolError> { + let mut header = [0u8; LENGTH_PREFIX_LEN]; + match stream.read_exact(&mut header).await { + Ok(()) => {} + Err(err) => return Err(classify_read_error(err)), + } + + let announced = u32::from_be_bytes(header) as u64; + if announced > max_frame_len as u64 { + return Err(ProtocolError::FrameTooLarge { + announced, + limit: max_frame_len, + }); + } + + // Safe: `announced` was just bounded by `max_frame_len`, a usize. + let mut payload = vec![0u8; announced as usize]; + if !payload.is_empty() { + match stream.read_exact(&mut payload).await { + Ok(()) => {} + Err(err) => return Err(classify_read_error(err)), + } + } + Ok(payload) +} + +fn classify_read_error(err: iroh::endpoint::ReadExactError) -> ProtocolError { + match err { + iroh::endpoint::ReadExactError::FinishedEarly(_) => ProtocolError::StreamClosed, + other => ProtocolError::Stream(other.to_string()), + } +} diff --git a/src/proto/handshake.rs b/src/proto/handshake.rs new file mode 100644 index 0000000..0de49f0 --- /dev/null +++ b/src/proto/handshake.rs @@ -0,0 +1,575 @@ +//! Mutual proof that both ends belong to the same network space. +//! +//! # Why a successful iroh connection is not enough +//! +//! iroh authenticates *endpoints*: after the QUIC/TLS handshake each side knows +//! the other's [`EndpointId`], because that id is the public key in the +//! certificate. It says nothing about network membership — anybody can dial us. +//! So on top of the authenticated connection we run an explicit mutual proof of +//! knowledge of the derived network authentication key. +//! +//! # Channel binding is not a proof by itself +//! +//! iroh exposes the TLS exporter (RFC 5705) via +//! [`Connection::export_keying_material`]. That gives both sides the same +//! secret bytes for *this* connection, which is exactly what is needed to stop +//! a proof being replayed on another connection. It proves nothing about the +//! shared network secret on its own, because both ends of any connection can +//! compute it. The proof of membership is the HMAC keyed by `auth_key`; the +//! exporter output is only one of its inputs. +//! +//! # The scheme +//! +//! ```text +//! cb = TLS-Exporter(label = "tsunagi/handshake/v1", context = network_id, 32) +//! LP(x)= u32_be(len(x)) || x +//! +//! transcript(role) = LP("tsunagi-handshake-v1") +//! || LP(role) // "initiator-proof" | "responder-proof" +//! || LP(u16_be(protocol_version)) +//! || LP(network_id) // 32 bytes +//! || LP(initiator_endpoint_id) // 32 bytes +//! || LP(responder_endpoint_id) // 32 bytes +//! || LP(cb) // 32 bytes +//! || LP(nonce_initiator) // 16 bytes +//! || LP(nonce_responder) // 16 bytes +//! +//! proof(role) = HMAC-SHA256(auth_key, transcript(role)) +//! ``` +//! +//! What each input buys: +//! +//! * `auth_key` — membership. Derived from name+secret only, see +//! [`crate::identity`]. +//! * `cb` — binding to this connection. A proof captured elsewhere is useless +//! here, because `cb` differs per TLS session. +//! * `network_id` — binding to this network space. +//! * both endpoint ids — binding to these two identities. +//! * distinct `role` labels — no reflection: the responder cannot bounce the +//! initiator's own proof back at it. +//! * both nonces — freshness contributed by each side. +//! +//! # Message order +//! +//! ```text +//! initiator -> responder : Hello { version, network_id, nonce_i } +//! initiator <- responder : HelloAck { version, nonce_r } +//! initiator -> responder : AuthProof{ proof(initiator) } +//! initiator <- responder : AuthProof{ proof(responder) } // only if the first proof verified +//! ``` +//! +//! The responder emits nothing derived from `auth_key` until the initiator's +//! proof has verified, so a caller who does not know the secret learns nothing. +//! Until both steps complete, no regular control message is accepted in either +//! direction. +//! +//! [`Connection::export_keying_material`]: iroh::endpoint::Connection::export_keying_material + +use hmac::{Hmac, KeyInit, Mac}; +use iroh::EndpointId; +use iroh::endpoint::{Connection, RecvStream, SendStream}; +use sha2::Sha256; +use zeroize::Zeroizing; + +use crate::config::Limits; +use crate::error::ProtocolError; +use crate::identity::{NetworkId, NetworkKeys}; +use crate::proto::frame::{read_frame, write_frame}; +use crate::proto::message::{AuthProof, Hello, HelloAck, PROTOCOL_VERSION, decode, encode}; + +/// Frozen domain separator of the handshake transcript. +pub const TRANSCRIPT_DOMAIN: &str = "tsunagi-handshake-v1"; + +/// TLS exporter label used for channel binding. +pub const EXPORTER_LABEL: &[u8] = b"tsunagi/handshake/v1"; + +/// Transcript role label of the side that dialled. +pub const ROLE_INITIATOR: &str = "initiator-proof"; + +/// Transcript role label of the side that accepted. +pub const ROLE_RESPONDER: &str = "responder-proof"; + +/// Length of the channel binding material, in bytes. +pub const CHANNEL_BINDING_LEN: usize = 32; + +/// Which side of the handshake this agent played. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// This agent dialled. + Initiator, + /// This agent accepted. + Responder, +} + +impl Role { + /// Short label for diagnostics. + pub fn as_str(&self) -> &'static str { + match self { + Role::Initiator => "initiator", + Role::Responder => "responder", + } + } +} + +/// Result of a completed handshake. +#[derive(Debug, Clone)] +pub struct HandshakeOutcome { + /// Network both sides proved membership of. + pub network_id: NetworkId, + /// Authenticated endpoint id of the peer, taken from the TLS certificate. + pub peer: EndpointId, + /// Which side this agent played. + pub role: Role, +} + +fn push_lp(out: &mut Vec, bytes: &[u8]) { + let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(bytes); +} + +/// Builds the role-specific transcript. Pure function, unit tested. +#[allow(clippy::too_many_arguments)] +pub fn transcript( + role: &str, + version: u16, + network_id: &[u8; 32], + initiator: &[u8; 32], + responder: &[u8; 32], + channel_binding: &[u8], + nonce_initiator: &[u8; 16], + nonce_responder: &[u8; 16], +) -> Vec { + let mut out = Vec::with_capacity(256); + push_lp(&mut out, TRANSCRIPT_DOMAIN.as_bytes()); + push_lp(&mut out, role.as_bytes()); + push_lp(&mut out, &version.to_be_bytes()); + push_lp(&mut out, network_id); + push_lp(&mut out, initiator); + push_lp(&mut out, responder); + push_lp(&mut out, channel_binding); + push_lp(&mut out, nonce_initiator); + push_lp(&mut out, nonce_responder); + out +} + +/// Computes one proof. +#[allow(clippy::too_many_arguments)] +fn proof( + auth_key: &[u8; 32], + role: &str, + version: u16, + network_id: &[u8; 32], + initiator: &[u8; 32], + responder: &[u8; 32], + channel_binding: &[u8], + nonce_initiator: &[u8; 16], + nonce_responder: &[u8; 16], +) -> [u8; 32] { + let message = transcript( + role, + version, + network_id, + initiator, + responder, + channel_binding, + nonce_initiator, + nonce_responder, + ); + let mut mac = match as KeyInit>::new_from_slice(auth_key) { + Ok(mac) => mac, + // HMAC-SHA256 accepts keys of any length, so a 32 byte key cannot fail. + Err(_) => unreachable!("HMAC-SHA256 accepts a 32 byte key"), + }; + mac.update(&message); + let tag = mac.finalize().into_bytes(); + let mut out = [0u8; 32]; + out.copy_from_slice(&tag); + out +} + +/// Verifies a proof in constant time. +#[allow(clippy::too_many_arguments)] +fn verify( + auth_key: &[u8; 32], + role: &str, + version: u16, + network_id: &[u8; 32], + initiator: &[u8; 32], + responder: &[u8; 32], + channel_binding: &[u8], + nonce_initiator: &[u8; 16], + nonce_responder: &[u8; 16], + candidate: &[u8; 32], +) -> Result<(), ProtocolError> { + let message = transcript( + role, + version, + network_id, + initiator, + responder, + channel_binding, + nonce_initiator, + nonce_responder, + ); + let mut mac = match as KeyInit>::new_from_slice(auth_key) { + Ok(mac) => mac, + Err(_) => unreachable!("HMAC-SHA256 accepts a 32 byte key"), + }; + mac.update(&message); + mac.verify_slice(candidate) + .map_err(|_| ProtocolError::AuthenticationFailed) +} + +/// Extracts channel binding material from the connection. +fn channel_binding( + conn: &Connection, + network_id: &[u8; 32], +) -> Result, ProtocolError> { + let mut out = Zeroizing::new([0u8; CHANNEL_BINDING_LEN]); + conn.export_keying_material(out.as_mut(), EXPORTER_LABEL, network_id) + .map_err(|err| ProtocolError::NoChannelBinding(format!("{err:?}")))?; + Ok(out) +} + +fn fresh_nonce() -> [u8; 16] { + let mut nonce = [0u8; 16]; + rand::fill(&mut nonce); + nonce +} + +fn check_version(found: u16) -> Result<(), ProtocolError> { + if found != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion { + found, + supported: PROTOCOL_VERSION, + }); + } + Ok(()) +} + +/// Runs the initiator side of the handshake. +/// +/// Bounded by [`Limits::handshake_timeout`]. +pub async fn initiate( + conn: &Connection, + send: &mut SendStream, + recv: &mut RecvStream, + local_id: EndpointId, + keys: &NetworkKeys, + limits: &Limits, +) -> Result { + tokio::time::timeout( + limits.handshake_timeout, + initiate_inner(conn, send, recv, local_id, keys, limits), + ) + .await + .unwrap_or(Err(ProtocolError::HandshakeTimeout)) +} + +async fn initiate_inner( + conn: &Connection, + send: &mut SendStream, + recv: &mut RecvStream, + local_id: EndpointId, + keys: &NetworkKeys, + limits: &Limits, +) -> Result { + let network_id = keys.network_id(); + let network_bytes = *network_id.as_bytes(); + let peer = conn.remote_id(); + + let initiator = *local_id.as_bytes(); + let responder = *peer.as_bytes(); + let cb = channel_binding(conn, &network_bytes)?; + + let nonce_i = fresh_nonce(); + let hello = Hello { + version: PROTOCOL_VERSION, + network_id: network_bytes, + nonce: nonce_i, + }; + write_frame(send, &encode(&hello)?, limits.max_frame_len).await?; + + let ack: HelloAck = decode(&read_frame(recv, limits.max_frame_len).await?)?; + check_version(ack.version)?; + let nonce_r = ack.nonce; + + let mine = proof( + keys.auth_key(), + ROLE_INITIATOR, + PROTOCOL_VERSION, + &network_bytes, + &initiator, + &responder, + cb.as_ref(), + &nonce_i, + &nonce_r, + ); + write_frame( + send, + &encode(&AuthProof { proof: mine })?, + limits.max_frame_len, + ) + .await?; + + let theirs: AuthProof = decode(&read_frame(recv, limits.max_frame_len).await?)?; + verify( + keys.auth_key(), + ROLE_RESPONDER, + PROTOCOL_VERSION, + &network_bytes, + &initiator, + &responder, + cb.as_ref(), + &nonce_i, + &nonce_r, + &theirs.proof, + )?; + + Ok(HandshakeOutcome { + network_id, + peer, + role: Role::Initiator, + }) +} + +/// Runs the responder side of the handshake. +/// +/// `lookup` maps the network id the peer asked for to the local key material, +/// returning `None` if this agent does not have that network active. Routing +/// stays in the agent; the protocol stays here. +/// +/// Bounded by [`Limits::handshake_timeout`]. +pub async fn respond( + conn: &Connection, + send: &mut SendStream, + recv: &mut RecvStream, + local_id: EndpointId, + limits: &Limits, + lookup: F, +) -> Result +where + F: FnOnce(NetworkId) -> Option, +{ + tokio::time::timeout( + limits.handshake_timeout, + respond_inner(conn, send, recv, local_id, limits, lookup), + ) + .await + .unwrap_or(Err(ProtocolError::HandshakeTimeout)) +} + +async fn respond_inner( + conn: &Connection, + send: &mut SendStream, + recv: &mut RecvStream, + local_id: EndpointId, + limits: &Limits, + lookup: F, +) -> Result +where + F: FnOnce(NetworkId) -> Option, +{ + let hello: Hello = decode(&read_frame(recv, limits.max_frame_len).await?)?; + check_version(hello.version)?; + + let network_id = NetworkId::from_bytes(hello.network_id); + let keys = lookup(network_id).ok_or(ProtocolError::UnknownNetwork)?; + + let peer = conn.remote_id(); + let network_bytes = hello.network_id; + let initiator = *peer.as_bytes(); + let responder = *local_id.as_bytes(); + let cb = channel_binding(conn, &network_bytes)?; + + let nonce_i = hello.nonce; + let nonce_r = fresh_nonce(); + let ack = HelloAck { + version: PROTOCOL_VERSION, + nonce: nonce_r, + }; + write_frame(send, &encode(&ack)?, limits.max_frame_len).await?; + + let theirs: AuthProof = decode(&read_frame(recv, limits.max_frame_len).await?)?; + verify( + keys.auth_key(), + ROLE_INITIATOR, + PROTOCOL_VERSION, + &network_bytes, + &initiator, + &responder, + cb.as_ref(), + &nonce_i, + &nonce_r, + &theirs.proof, + )?; + + // Only now, after the peer proved membership, do we emit our own proof. + let mine = proof( + keys.auth_key(), + ROLE_RESPONDER, + PROTOCOL_VERSION, + &network_bytes, + &initiator, + &responder, + cb.as_ref(), + &nonce_i, + &nonce_r, + ); + write_frame( + send, + &encode(&AuthProof { proof: mine })?, + limits.max_frame_len, + ) + .await?; + + Ok(HandshakeOutcome { + network_id, + peer, + role: Role::Responder, + }) +} + +/// Test-only re-export of [`channel_binding`]. See [`crate::test_support`]. +#[doc(hidden)] +pub fn channel_binding_for_test( + conn: &Connection, + network_id: &[u8; 32], +) -> Result<[u8; 32], ProtocolError> { + channel_binding(conn, network_id).map(|cb| *cb) +} + +/// Test-only re-export of the proof function. See [`crate::test_support`]. +#[doc(hidden)] +#[allow(clippy::too_many_arguments)] +pub fn proof_for_test( + auth_key: &[u8; 32], + role: &str, + version: u16, + network_id: &[u8; 32], + initiator: &[u8; 32], + responder: &[u8; 32], + channel_binding: &[u8], + nonce_initiator: &[u8; 16], + nonce_responder: &[u8; 16], +) -> [u8; 32] { + proof( + auth_key, + role, + version, + network_id, + initiator, + responder, + channel_binding, + nonce_initiator, + nonce_responder, + ) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + /// `(auth_key, network_id, initiator_id, responder_id, nonce_i, nonce_r)` + type Fixture = ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 16], [u8; 16]); + + fn fixture() -> Fixture { + ( + [1u8; 32], [2u8; 32], [3u8; 32], [4u8; 32], [5u8; 16], [6u8; 16], + ) + } + + #[test] + fn role_labels_produce_different_transcripts() { + let (key, net, ini, res, ni, nr) = fixture(); + let cb = [7u8; 32]; + let a = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr); + let b = proof(&key, ROLE_RESPONDER, 1, &net, &ini, &res, &cb, &ni, &nr); + assert_ne!(a, b, "reflecting a proof back must not verify"); + } + + #[test] + fn channel_binding_changes_the_proof() { + let (key, net, ini, res, ni, nr) = fixture(); + let a = proof( + &key, + ROLE_INITIATOR, + 1, + &net, + &ini, + &res, + &[7u8; 32], + &ni, + &nr, + ); + let b = proof( + &key, + ROLE_INITIATOR, + 1, + &net, + &ini, + &res, + &[8u8; 32], + &ni, + &nr, + ); + assert_ne!(a, b, "a proof must not be replayable on another connection"); + } + + #[test] + fn identities_and_network_are_bound() { + let (key, net, ini, res, ni, nr) = fixture(); + let cb = [7u8; 32]; + let base = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr); + let other_net = proof( + &key, + ROLE_INITIATOR, + 1, + &[9u8; 32], + &ini, + &res, + &cb, + &ni, + &nr, + ); + let swapped = proof(&key, ROLE_INITIATOR, 1, &net, &res, &ini, &cb, &ni, &nr); + assert_ne!(base, other_net); + assert_ne!(base, swapped); + } + + #[test] + fn transcript_encoding_is_unambiguous() { + // Two different field splits that would collide under naive concatenation. + let a = transcript( + "ab", 1, &[0u8; 32], &[0u8; 32], &[0u8; 32], b"cd", &[0u8; 16], &[0u8; 16], + ); + let b = transcript( + "a", 1, &[0u8; 32], &[0u8; 32], &[0u8; 32], b"bcd", &[0u8; 16], &[0u8; 16], + ); + assert_ne!(a, b); + } + + #[test] + fn wrong_key_fails_verification() { + let (key, net, ini, res, ni, nr) = fixture(); + let cb = [7u8; 32]; + let tag = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr); + let wrong = [0xAAu8; 32]; + let result = verify( + &wrong, + ROLE_INITIATOR, + 1, + &net, + &ini, + &res, + &cb, + &ni, + &nr, + &tag, + ); + assert!(matches!(result, Err(ProtocolError::AuthenticationFailed))); + } +} diff --git a/src/proto/message.rs b/src/proto/message.rs new file mode 100644 index 0000000..c2ecd0c --- /dev/null +++ b/src/proto/message.rs @@ -0,0 +1,184 @@ +//! Control message formats. +//! +//! This is deliberately a small, closed set of messages, not a general RPC +//! framework. Bodies are encoded with [postcard], a compact, deterministic, +//! non-self-describing serde format. +//! +//! Every message that arrives from the network passes [`validate`] against the +//! configured [`Limits`] before it reaches anything else. +//! +//! [postcard]: https://docs.rs/postcard + +use serde::{Deserialize, Serialize}; + +use crate::config::Limits; +use crate::dataplane::{MAX_PROTOCOL_ID_LEN, PluginCapability}; +use crate::error::ProtocolError; + +/// ALPN of the tsunagi control plane. +/// +/// The version in the ALPN is the wire-compatibility version of the control +/// protocol. It is independent of the network identity scheme version, so +/// bumping it must not change any existing [`crate::NetworkId`]. +pub const ALPN: &[u8] = b"tsunagi/ctrl/1"; + +/// Control protocol version carried inside the handshake. +pub const PROTOCOL_VERSION: u16 = 1; + +/// First message of the handshake, sent by the initiator. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hello { + /// Control protocol version the initiator speaks. + pub version: u16, + /// Public network identifier the initiator wants to join. + pub network_id: [u8; 32], + /// Initiator's fresh handshake nonce. + pub nonce: [u8; 16], +} + +/// Responder's reply to [`Hello`]. Carries no proof yet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HelloAck { + /// Control protocol version the responder speaks. + pub version: u16, + /// Responder's fresh handshake nonce. + pub nonce: [u8; 16], +} + +/// A handshake proof, i.e. one HMAC tag over a role-specific transcript. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthProof { + /// HMAC-SHA256 tag. + pub proof: [u8; 32], +} + +/// What this agent tells a peer about itself. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Announcement { + /// Human-readable hostname. A mutable binding, not an identity. + pub hostname: String, + /// Announced IP plugin capabilities. Opaque to the core. + pub capabilities: Vec, +} + +/// A control message exchanged after a successful handshake. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum ControlMessage { + /// Hostname and capability announcement. + Announce(Announcement), + /// A small request used to verify that the exchange works. + Ping { + /// Caller-chosen sequence number, echoed back. + seq: u64, + /// Opaque bounded payload, echoed back. + payload: Vec, + }, + /// The reply to a [`ControlMessage::Ping`]. + Pong { + /// Sequence number of the request being answered. + seq: u64, + /// Echoed payload. + payload: Vec, + }, + /// Graceful goodbye. + /// + /// A peer going away is not a revocation of anything. + Bye { + /// Short free-text reason. + reason: String, + }, +} + +/// A control message together with the network it belongs to. +/// +/// Every session is bound to exactly one network at handshake time. The +/// `network_id` here is re-checked on every message, so an authenticated +/// session for network A can never be used to speak to network B, even over a +/// shared physical connection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Envelope { + /// Network this message belongs to. + pub network_id: [u8; 32], + /// The message itself. + pub message: ControlMessage, +} + +/// Encodes a value into a postcard byte vector. +pub fn encode(value: &T) -> Result, ProtocolError> { + postcard::to_stdvec(value).map_err(|_| ProtocolError::Malformed("cannot encode message")) +} + +/// Decodes a value from postcard bytes. +pub fn decode Deserialize<'de>>(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes).map_err(|_| ProtocolError::Malformed("cannot decode message")) +} + +fn check_len(field: &'static str, len: usize, limit: usize) -> Result<(), ProtocolError> { + if len > limit { + return Err(ProtocolError::FieldTooLarge { field, len, limit }); + } + Ok(()) +} + +/// Validates a decoded capability against the configured limits. +pub fn validate_capability( + capability: &PluginCapability, + limits: &Limits, +) -> Result<(), ProtocolError> { + if capability.protocol.is_empty() { + return Err(ProtocolError::Malformed("empty plugin protocol id")); + } + check_len( + "capability.protocol", + capability.protocol.len(), + MAX_PROTOCOL_ID_LEN, + )?; + check_len( + "capability.data", + capability.data.len(), + limits.max_capability_data_len, + )?; + Ok(()) +} + +/// Validates a decoded control message against the configured limits. +/// +/// Returning an error rejects that single message. It never stops the session's +/// network, the other networks or the agent. +pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> { + match message { + ControlMessage::Announce(announcement) => { + check_len( + "announce.hostname", + announcement.hostname.len(), + limits.max_hostname_len, + )?; + check_len( + "announce.capabilities", + announcement.capabilities.len(), + limits.max_capabilities, + )?; + for capability in &announcement.capabilities { + validate_capability(capability, limits)?; + } + } + ControlMessage::Ping { payload, .. } | ControlMessage::Pong { payload, .. } => { + check_len("echo.payload", payload.len(), limits.max_echo_payload_len)?; + } + ControlMessage::Bye { reason } => { + check_len("bye.reason", reason.len(), limits.max_reason_len)?; + } + } + Ok(()) +} + +/// A short, stable label for a message kind, for metrics and diagnostics. +pub fn kind(message: &ControlMessage) -> &'static str { + match message { + ControlMessage::Announce(_) => "announce", + ControlMessage::Ping { .. } => "ping", + ControlMessage::Pong { .. } => "pong", + ControlMessage::Bye { .. } => "bye", + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs new file mode 100644 index 0000000..8c36c86 --- /dev/null +++ b/src/proto/mod.rs @@ -0,0 +1,27 @@ +//! The control protocol: framing, messages and the network membership +//! handshake. +//! +//! Only control messages travel over iroh. User IP traffic is never tunnelled +//! through this protocol. +//! +//! Layering, outermost first: +//! +//! 1. iroh/QUIC connection with ALPN [`message::ALPN`] — endpoint +//! authentication, confidentiality and integrity. +//! 2. One bidirectional stream per session, carrying length-prefixed frames +//! ([`frame`]). +//! 3. The [`handshake`], which must complete before anything else is accepted. +//! 4. [`message::Envelope`]s carrying [`message::ControlMessage`]s, each +//! re-checked against the session's network id. +//! +//! Nothing here adds its own encryption on top of iroh. + +pub mod frame; +pub mod handshake; +pub mod message; + +pub use frame::{read_frame, write_frame}; +pub use handshake::{HandshakeOutcome, Role}; +pub use message::{ + ALPN, Announcement, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, +}; diff --git a/src/storage/cache.rs b/src/storage/cache.rs new file mode 100644 index 0000000..4e63ed2 --- /dev/null +++ b/src/storage/cache.rs @@ -0,0 +1,231 @@ +//! The disposable cache store, `cache.sqlite`. +//! +//! Everything here is recoverable. A missing cache is recreated, a corrupt one +//! is thrown away and recreated, and a stale one is simply wrong data that the +//! rest of the system is expected to tolerate. +//! +//! Crucially, a stale cache never bypasses identity or network authentication: +//! cached hints only produce *candidates*, which still have to pass the +//! handshake. + +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, params}; + +use crate::error::{Error, Result}; +use crate::identity::NetworkId; + +/// Schema version written by this build. +pub const SCHEMA_VERSION: i64 = 1; + +/// A cached address hint for one peer in one network. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressHint { + /// Network the hint belongs to. + pub network_id: NetworkId, + /// Peer endpoint id, 32 bytes. + pub endpoint_id: [u8; 32], + /// Serialised address, currently `ip:` or `relay:`. + pub addr: String, + /// Unix seconds when this hint was last confirmed. + pub last_seen: i64, +} + +/// Why the cache had to be recreated, if it did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CacheOutcome { + /// Opened normally. + Opened, + /// Created because nothing was there. + Created, + /// Discarded and recreated. The reason is free of secrets. + Reset(String), +} + +/// The disposable cache store. +#[derive(Debug)] +pub struct CacheStore { + conn: Connection, + path: PathBuf, +} + +impl CacheStore { + /// Opens the cache, discarding and recreating it if it is unusable. + pub fn open_or_reset(path: impl AsRef) -> Result<(Self, CacheOutcome)> { + let path = path.as_ref().to_path_buf(); + let existed = path.exists(); + + match Self::try_open(&path, existed) { + Ok(store) => Ok(( + store, + if existed { + CacheOutcome::Opened + } else { + CacheOutcome::Created + }, + )), + Err(reason) => { + tracing::warn!(path = %path.display(), %reason, "discarding unusable cache"); + Self::remove_files(&path); + let store = Self::try_open(&path, false) + .map_err(|err| Error::Storage(format!("cannot recreate cache: {err}")))?; + Ok((store, CacheOutcome::Reset(reason))) + } + } + } + + fn try_open(path: &Path, check_integrity: bool) -> std::result::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("cannot create cache directory: {err}"))?; + } + let conn = Connection::open(path).map_err(|err| format!("cannot open: {err}"))?; + super::restrict_path_permissions(path).map_err(|err| format!("{err}"))?; + super::apply_common_pragmas(&conn).map_err(|err| format!("cannot configure: {err}"))?; + + if check_integrity { + let integrity: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .map_err(|err| format!("integrity check failed: {err}"))?; + if integrity != "ok" { + return Err(format!("integrity check reported: {integrity}")); + } + } + + let found: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|err| format!("cannot read schema version: {err}"))?; + if found > SCHEMA_VERSION { + return Err(format!( + "cache schema version {found} is newer than {SCHEMA_VERSION}" + )); + } + if found < SCHEMA_VERSION { + conn.execute_batch( + "BEGIN; + DROP TABLE IF EXISTS address_hints; + CREATE TABLE address_hints ( + network_id BLOB NOT NULL, + endpoint_id BLOB NOT NULL, + addr TEXT NOT NULL, + last_seen INTEGER NOT NULL, + PRIMARY KEY (network_id, endpoint_id, addr) + ); + PRAGMA user_version = 1; + COMMIT;", + ) + .map_err(|err| format!("cannot create cache schema: {err}"))?; + } else { + conn.query_row("SELECT count(*) FROM address_hints", [], |row| { + row.get::<_, i64>(0) + }) + .map_err(|err| format!("cache schema is unusable: {err}"))?; + } + + Ok(Self { + conn, + path: path.to_path_buf(), + }) + } + + fn remove_files(path: &Path) { + for suffix in ["", "-wal", "-shm", "-journal"] { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + let _ = std::fs::remove_file(PathBuf::from(name)); + } + } + + /// Path of the underlying file. + pub fn path(&self) -> &Path { + &self.path + } + + /// Records an address hint, keeping at most `max_per_peer` newest entries. + pub fn record_hint( + &self, + network_id: NetworkId, + endpoint_id: &[u8; 32], + addr: &str, + max_per_peer: usize, + ) -> Result<()> { + self.conn + .execute( + "INSERT INTO address_hints (network_id, endpoint_id, addr, last_seen) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(network_id, endpoint_id, addr) + DO UPDATE SET last_seen = excluded.last_seen", + params![ + network_id.as_bytes().as_slice(), + endpoint_id.as_slice(), + addr, + super::state::now_unix() + ], + ) + .map_err(|err| Error::Storage(format!("cannot record address hint: {err}")))?; + + self.conn + .execute( + "DELETE FROM address_hints + WHERE network_id = ?1 AND endpoint_id = ?2 AND addr NOT IN ( + SELECT addr FROM address_hints + WHERE network_id = ?1 AND endpoint_id = ?2 + ORDER BY last_seen DESC LIMIT ?3 + )", + params![ + network_id.as_bytes().as_slice(), + endpoint_id.as_slice(), + max_per_peer as i64 + ], + ) + .map_err(|err| Error::Storage(format!("cannot prune address hints: {err}")))?; + Ok(()) + } + + /// Returns every hint known for a network. + pub fn hints_for_network(&self, network_id: NetworkId) -> Result> { + let mut stmt = self + .conn + .prepare( + "SELECT endpoint_id, addr, last_seen FROM address_hints + WHERE network_id = ?1 ORDER BY last_seen DESC", + ) + .map_err(|err| Error::Storage(format!("cannot read address hints: {err}")))?; + let rows = stmt + .query_map(params![network_id.as_bytes().as_slice()], |row| { + let endpoint_id: Vec = row.get(0)?; + let addr: String = row.get(1)?; + let last_seen: i64 = row.get(2)?; + Ok((endpoint_id, addr, last_seen)) + }) + .map_err(|err| Error::Storage(format!("cannot read address hints: {err}")))?; + + let mut out = Vec::new(); + for row in rows { + let (endpoint_id, addr, last_seen) = + row.map_err(|err| Error::Storage(format!("cannot read hint row: {err}")))?; + // A malformed row in a disposable store is skipped, not fatal. + let Ok(endpoint_id) = <[u8; 32]>::try_from(endpoint_id.as_slice()) else { + continue; + }; + out.push(AddressHint { + network_id, + endpoint_id, + addr, + last_seen, + }); + } + Ok(out) + } + + /// Drops all hints for a network. + pub fn forget_network(&self, network_id: NetworkId) -> Result<()> { + self.conn + .execute( + "DELETE FROM address_hints WHERE network_id = ?1", + params![network_id.as_bytes().as_slice()], + ) + .map_err(|err| Error::Storage(format!("cannot clear address hints: {err}")))?; + Ok(()) + } +} diff --git a/src/storage/lock.rs b/src/storage/lock.rs new file mode 100644 index 0000000..7aec166 --- /dev/null +++ b/src/storage/lock.rs @@ -0,0 +1,64 @@ +//! Ownership lock for a state directory. +//! +//! One persistent state directory belongs to exactly one live agent instance. +//! Checking whether a file exists is not enough — a stale file from a crashed +//! process must not block a restart, and two concurrently starting agents must +//! not both win. An advisory OS file lock gives both properties. + +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; + +use fs4::{FileExt, TryLockError}; + +use crate::error::{Error, Result}; + +/// An exclusive lock held for the lifetime of an agent. +/// +/// Dropping it releases the lock, so a cleanly stopped agent leaves the +/// directory immediately reopenable. +#[derive(Debug)] +pub struct DirectoryLock { + file: File, + path: PathBuf, +} + +impl DirectoryLock { + /// Acquires the lock, failing fast if another live agent holds it. + pub fn acquire(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .map_err(|source| Error::Io { + path: path.clone(), + source, + })?; + super::restrict_permissions(&file, &path)?; + + match FileExt::try_lock(&file) { + Ok(()) => Ok(Self { file, path }), + Err(TryLockError::WouldBlock) => Err(Error::StateLocked { + path: path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| path.clone()), + }), + Err(TryLockError::Error(source)) => Err(Error::Io { path, source }), + } + } + + /// The path of the lock file. + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for DirectoryLock { + fn drop(&mut self) { + // Best effort: the OS releases the lock when the descriptor closes anyway. + let _ = FileExt::unlock(&self.file); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..e2af374 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,310 @@ +//! Persistence, split into mandatory state and a disposable cache. +//! +//! | store | contents | on damage | +//! |----------------|--------------------------------------------------------|-----------| +//! | `state.sqlite` | device identity, network configuration, hostname | hard error | +//! | `cache.sqlite` | address hints and other recoverable data | discarded and recreated | +//! +//! Both files are created with owner-only permissions where the platform +//! supports it. The state directory additionally carries an ownership lock, see +//! [`DirectoryLock`]. +//! +//! SQLite is synchronous. Every call that touches a database therefore runs on +//! a blocking pool via [`tokio::task::spawn_blocking`], and no database lock is +//! ever held across a network `await`. + +mod cache; +mod lock; +mod state; + +pub use cache::{AddressHint, CacheOutcome, CacheStore}; +pub use lock::DirectoryLock; +pub use state::{SCHEMA_VERSION, StateStore, StoredNetwork}; + +use std::path::Path; +use std::sync::{Arc, Mutex, MutexGuard}; + +use crate::config::StoragePaths; +use crate::error::{Error, Result}; +use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret}; + +/// Applies the pragmas both stores share. +fn apply_common_pragmas(conn: &rusqlite::Connection) -> rusqlite::Result<()> { + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + Ok(()) +} + +/// Restricts a file to the current user where the platform supports it. +#[cfg(unix)] +fn restrict_permissions(file: &std::fs::File, path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + file.set_permissions(perms).map_err(|source| Error::Io { + path: path.to_path_buf(), + source, + }) +} + +/// On Windows, files inherit the parent directory's ACL, which for the +/// per-user application data directory is already restricted to that user. +#[cfg(not(unix))] +fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn restrict_path_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| { + Error::Io { + path: path.to_path_buf(), + source, + } + }) +} + +#[cfg(not(unix))] +fn restrict_path_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn restrict_dir_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| { + Error::Io { + path: path.to_path_buf(), + source, + } + }) +} + +#[cfg(not(unix))] +fn restrict_dir_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +fn create_dir(path: &Path) -> Result<()> { + std::fs::create_dir_all(path).map_err(|source| Error::Io { + path: path.to_path_buf(), + source, + })?; + restrict_dir_permissions(path) +} + +/// Async facade over both stores, holding the directory ownership lock. +/// +/// Cloning shares the same underlying connections and the same lock. +#[derive(Debug, Clone)] +pub struct Storage { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + state: Mutex, + cache: Mutex>, + cache_outcome: CacheOutcome, + paths: StoragePaths, + lock: Mutex>, +} + +impl Storage { + /// Opens both stores and takes the ownership lock on the state directory. + /// + /// Fails with [`Error::StateLocked`] if another live agent owns the state + /// directory, and with [`Error::StateCorrupted`] if the mandatory state is + /// unusable. A broken cache is silently discarded and reported through + /// [`Storage::cache_outcome`]. + pub fn open(paths: &StoragePaths) -> Result { + create_dir(&paths.state_dir)?; + create_dir(&paths.cache_dir)?; + + let lock = DirectoryLock::acquire(paths.lock_file())?; + let state = StateStore::open(paths.state_db())?; + let (cache, cache_outcome) = CacheStore::open_or_reset(paths.cache_db())?; + + Ok(Self { + inner: Arc::new(Inner { + state: Mutex::new(state), + cache: Mutex::new(Some(cache)), + cache_outcome, + paths: paths.clone(), + lock: Mutex::new(Some(lock)), + }), + }) + } + + /// What happened to the cache when the agent started. + pub fn cache_outcome(&self) -> &CacheOutcome { + &self.inner.cache_outcome + } + + /// The configured paths. + pub fn paths(&self) -> &StoragePaths { + &self.inner.paths + } + + fn lock_state(&self) -> MutexGuard<'_, StateStore> { + match self.inner.state.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_cache(&self) -> MutexGuard<'_, Option> { + match self.inner.cache.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + /// Runs a closure against the mandatory state store on the blocking pool. + async fn with_state(&self, f: F) -> Result + where + T: Send + 'static, + F: FnOnce(&StateStore) -> Result + Send + 'static, + { + let inner = Arc::clone(&self.inner); + tokio::task::spawn_blocking(move || { + let guard = match inner.state.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + f(&guard) + }) + .await + .map_err(|err| Error::Storage(format!("state task failed: {err}")))? + } + + /// Runs a closure against the cache, tolerating an unavailable cache. + /// + /// If the cache has been disabled because it misbehaved, the closure is + /// skipped and `default` is returned. + async fn with_cache(&self, default: T, f: F) -> T + where + T: Send + 'static, + F: FnOnce(&CacheStore) -> Result + Send + 'static, + { + let inner = Arc::clone(&self.inner); + let joined = tokio::task::spawn_blocking(move || { + let guard = match inner.cache.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + match guard.as_ref() { + Some(cache) => f(cache), + None => Err(Error::Storage("cache is unavailable".into())), + } + }) + .await; + + match joined { + Ok(Ok(value)) => value, + Ok(Err(err)) => { + tracing::debug!(%err, "cache operation failed; continuing without it"); + default + } + Err(err) => { + tracing::debug!(%err, "cache task failed; continuing without it"); + default + } + } + } + + /// Loads or creates the persistent device identity. + pub async fn device_identity(&self) -> Result { + self.with_state(|state| state.load_or_create_device_identity()) + .await + } + + /// Lists configured networks. + pub async fn list_networks(&self) -> Result> { + self.with_state(|state| state.list_networks()).await + } + + /// Stores or updates a network configuration. + pub async fn upsert_network( + &self, + network_id: NetworkId, + name: NetworkName, + secret: NetworkSecret, + auto_start: bool, + ) -> Result<()> { + self.with_state(move |state| state.upsert_network(network_id, &name, &secret, auto_start)) + .await + } + + /// Updates the auto-start flag of a network. + pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> { + self.with_state(move |state| state.set_auto_start(network_id, auto_start)) + .await + } + + /// Removes a network configuration and its cached hints. + pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> { + self.with_state(move |state| state.remove_network(network_id)) + .await?; + self.with_cache((), move |cache| cache.forget_network(network_id)) + .await; + Ok(()) + } + + /// Reads the stored hostname. + pub async fn hostname(&self) -> Result> { + self.with_state(|state| state.hostname()).await + } + + /// Writes the stored hostname. + pub async fn set_hostname(&self, hostname: String) -> Result<()> { + self.with_state(move |state| state.set_hostname(&hostname)) + .await + } + + /// Records an address hint. Failures are non-fatal. + pub async fn record_hint( + &self, + network_id: NetworkId, + endpoint_id: [u8; 32], + addr: String, + max_per_peer: usize, + ) { + self.with_cache((), move |cache| { + cache.record_hint(network_id, &endpoint_id, &addr, max_per_peer) + }) + .await; + } + + /// Reads cached address hints. Returns an empty list if the cache is gone. + pub async fn hints_for_network(&self, network_id: NetworkId) -> Vec { + self.with_cache(Vec::new(), move |cache| cache.hints_for_network(network_id)) + .await + } + + /// Synchronously reads the hostname. Used only during startup. + pub(crate) fn hostname_blocking(&self) -> Result> { + self.lock_state().hostname() + } + + /// Whether the cache is currently usable. + pub fn cache_healthy(&self) -> bool { + self.lock_cache().is_some() + } + + /// Releases the state directory ownership lock. + /// + /// Called by [`crate::Agent::shutdown`] so that a cleanly stopped agent + /// leaves its directory immediately claimable by another instance. The + /// databases stay open and readable, but this handle no longer owns the + /// directory and must not be used to write after this point. + pub fn release_ownership_lock(&self) { + let mut guard = match self.inner.lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + guard.take(); + } +} diff --git a/src/storage/state.rs b/src/storage/state.rs new file mode 100644 index 0000000..d4c1aa0 --- /dev/null +++ b/src/storage/state.rs @@ -0,0 +1,331 @@ +//! The mandatory state store, `state.sqlite`. +//! +//! Holds the persistent device identity, configured networks (including their +//! shared secrets, which are needed to re-derive keys after a restart), the +//! stored hostname and auto-start flags. +//! +//! Corruption is reported, never silently repaired: a damaged state store must +//! not quietly turn into a brand new identity. +//! +//! Future work will add per-author record versions, accepted signed states and +//! revocations here. When that lands, writing an event and bumping the author's +//! own counter must happen in one SQLite transaction *before* the change is +//! published to the network. + +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::error::{Error, Result}; +use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret}; + +/// Schema version written by this build. +pub const SCHEMA_VERSION: i64 = 1; + +/// Key of the stored hostname setting. +const SETTING_HOSTNAME: &str = "hostname"; + +/// A network as persisted in the state store. +/// +/// The secret is held in a [`NetworkSecret`], which redacts itself from `Debug` +/// and zeroizes on drop. +#[derive(Debug, Clone)] +pub struct StoredNetwork { + /// Derived public network identifier. + pub network_id: NetworkId, + /// Network name. + pub name: NetworkName, + /// Shared secret, needed to re-derive keys after restart. + pub secret: NetworkSecret, + /// Whether the network is activated automatically at agent startup. + pub auto_start: bool, +} + +/// The mandatory state store. +#[derive(Debug)] +pub struct StateStore { + conn: Connection, + path: PathBuf, +} + +impl StateStore { + /// Opens (creating if absent) the state store at `path`. + /// + /// Returns [`Error::StateCorrupted`] if the file exists but is not a usable + /// database. The file is never deleted or recreated by this function. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let existed = path.exists(); + let conn = Connection::open(&path).map_err(|err| Error::StateCorrupted { + path: path.clone(), + reason: format!("cannot open database: {err}"), + })?; + + super::restrict_path_permissions(&path)?; + super::apply_common_pragmas(&conn).map_err(|err| Error::StateCorrupted { + path: path.clone(), + reason: format!("cannot configure database: {err}"), + })?; + + if existed { + let integrity: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .map_err(|err| Error::StateCorrupted { + path: path.clone(), + reason: format!("integrity check failed: {err}"), + })?; + if integrity != "ok" { + return Err(Error::StateCorrupted { + path, + reason: format!("integrity check reported: {integrity}"), + }); + } + } + + let store = Self { conn, path }; + store.migrate()?; + Ok(store) + } + + /// Path of the underlying file. + pub fn path(&self) -> &Path { + &self.path + } + + fn corrupt(&self, reason: impl std::fmt::Display) -> Error { + Error::StateCorrupted { + path: self.path.clone(), + reason: reason.to_string(), + } + } + + fn migrate(&self) -> Result<()> { + let found: i64 = self + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(|err| self.corrupt(format!("cannot read schema version: {err}")))?; + + if found > SCHEMA_VERSION { + return Err(Error::UnsupportedSchema { + found, + supported: SCHEMA_VERSION, + }); + } + if found == SCHEMA_VERSION { + return self.verify_shape(); + } + + // Migration 0 -> 1: initial schema. + if found < 1 { + self.conn + .execute_batch( + "BEGIN; + CREATE TABLE device_identity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + secret_key BLOB NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE networks ( + network_id BLOB PRIMARY KEY, + name TEXT NOT NULL, + secret BLOB NOT NULL, + auto_start INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL + ); + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + PRAGMA user_version = 1; + COMMIT;", + ) + .map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?; + } + Ok(()) + } + + /// Confirms the expected tables exist, so that a truncated or foreign + /// database is reported rather than used. + fn verify_shape(&self) -> Result<()> { + for table in ["device_identity", "networks", "settings"] { + let present: Option = self + .conn + .query_row( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1", + params![table], + |row| row.get(0), + ) + .optional() + .map_err(|err| self.corrupt(format!("cannot inspect schema: {err}")))?; + if present.is_none() { + return Err(self.corrupt(format!("table `{table}` is missing"))); + } + } + Ok(()) + } + + /// Loads the stored device identity, creating one on first use. + /// + /// A stored key of the wrong length is a corruption error, never a reason to + /// silently mint a new identity. + pub fn load_or_create_device_identity(&self) -> Result { + let stored: Option> = self + .conn + .query_row( + "SELECT secret_key FROM device_identity WHERE id = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|err| self.corrupt(format!("cannot read device identity: {err}")))?; + + if let Some(bytes) = stored { + let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { + self.corrupt(format!( + "stored device key has {} bytes, expected 32; refusing to replace it", + bytes.len() + )) + })?; + return Ok(DeviceIdentity::from_secret_bytes(&bytes)); + } + + let identity = DeviceIdentity::generate(); + self.conn + .execute( + "INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2)", + params![identity.secret_bytes().as_slice(), now_unix()], + ) + .map_err(|err| self.corrupt(format!("cannot store device identity: {err}")))?; + Ok(identity) + } + + /// Inserts or updates a network configuration. + pub fn upsert_network( + &self, + network_id: NetworkId, + name: &NetworkName, + secret: &NetworkSecret, + auto_start: bool, + ) -> Result<()> { + self.conn + .execute( + "INSERT INTO networks (network_id, name, secret, auto_start, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(network_id) DO UPDATE SET + name = excluded.name, + secret = excluded.secret, + auto_start = excluded.auto_start", + params![ + network_id.as_bytes().as_slice(), + name.as_str(), + secret.expose(), + auto_start as i64, + now_unix() + ], + ) + .map_err(|err| Error::Storage(format!("cannot store network: {err}")))?; + Ok(()) + } + + /// Sets the auto-start flag of a configured network. + pub fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> { + self.conn + .execute( + "UPDATE networks SET auto_start = ?2 WHERE network_id = ?1", + params![network_id.as_bytes().as_slice(), auto_start as i64], + ) + .map_err(|err| Error::Storage(format!("cannot update network: {err}")))?; + Ok(()) + } + + /// Removes a network configuration entirely. + pub fn remove_network(&self, network_id: NetworkId) -> Result<()> { + self.conn + .execute( + "DELETE FROM networks WHERE network_id = ?1", + params![network_id.as_bytes().as_slice()], + ) + .map_err(|err| Error::Storage(format!("cannot remove network: {err}")))?; + Ok(()) + } + + /// Lists every configured network. + pub fn list_networks(&self) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT network_id, name, secret, auto_start FROM networks ORDER BY name") + .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; + let rows = stmt + .query_map([], |row| { + let id: Vec = row.get(0)?; + let name: String = row.get(1)?; + let secret: Vec = row.get(2)?; + let auto_start: i64 = row.get(3)?; + Ok((id, name, secret, auto_start != 0)) + }) + .map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?; + + let mut out = Vec::new(); + for row in rows { + let (id, name, secret, auto_start) = + row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?; + let id: [u8; 32] = id + .as_slice() + .try_into() + .map_err(|_| self.corrupt("stored network id is not 32 bytes"))?; + out.push(StoredNetwork { + network_id: NetworkId::from_bytes(id), + name: NetworkName::new(name)?, + secret: NetworkSecret::from_bytes(secret)?, + auto_start, + }); + } + Ok(out) + } + + /// Reads the stored hostname, if any. + pub fn hostname(&self) -> Result> { + self.get_setting(SETTING_HOSTNAME) + } + + /// Stores the hostname. + /// + /// Today this is a local setting. In the future a rename must be a signed + /// record that revokes the specific old binding and announces the new one, + /// ideally atomically in one record. + pub fn set_hostname(&self, hostname: &str) -> Result<()> { + self.set_setting(SETTING_HOSTNAME, hostname) + } + + /// Reads an arbitrary setting. + pub fn get_setting(&self, key: &str) -> Result> { + self.conn + .query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |row| row.get(0), + ) + .optional() + .map_err(|err| Error::Storage(format!("cannot read setting `{key}`: {err}"))) + } + + /// Writes an arbitrary setting. + pub fn set_setting(&self, key: &str, value: &str) -> Result<()> { + self.conn + .execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value], + ) + .map_err(|err| Error::Storage(format!("cannot write setting `{key}`: {err}")))?; + Ok(()) + } +} + +/// Seconds since the Unix epoch, saturating at 0 before it. +pub(crate) fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} diff --git a/tests/authentication.rs b/tests/authentication.rs new file mode 100644 index 0000000..5d00454 --- /dev/null +++ b/tests/authentication.rs @@ -0,0 +1,425 @@ +//! Scenarios 3 and 9: an attacker without the secret, and the protocol's +//! boundaries. +//! +//! These tests speak the wire protocol directly against a real agent, because +//! that is the only way to present a *correct* public network id with a wrong +//! proof, replay a captured proof on a second connection, or send a message +//! before authenticating. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use common::{TestAgent, network, settle, wait_event}; +use iroh::endpoint::{Connection, PortmapperConfig, RecvStream, SendStream, presets}; +use iroh::{Endpoint, EndpointAddr, RelayMode}; +use tsunagi::agent::Event; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkId, NetworkKeys, NetworkName, NetworkSecret}; +use tsunagi::proto::handshake::{ROLE_INITIATOR, ROLE_RESPONDER}; +use tsunagi::proto::message::{ + ALPN, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, decode, encode, +}; +use tsunagi::proto::{read_frame, write_frame}; +use tsunagi::test_support; + +/// A bare iroh endpoint with no tsunagi agent behind it. +async fn raw_endpoint() -> Endpoint { + Endpoint::builder(presets::Minimal) + .alpns(vec![ALPN.to_vec()]) + .relay_mode(RelayMode::Disabled) + .clear_address_lookup() + .portmapper_config(PortmapperConfig::Disabled) + .clear_ip_transports() + .bind_addr("127.0.0.1:0") + .unwrap() + .bind() + .await + .unwrap() +} + +async fn open_control_stream( + endpoint: &Endpoint, + target: EndpointAddr, +) -> (Connection, SendStream, RecvStream) { + let conn = endpoint.connect(target, ALPN).await.unwrap(); + let (send, recv) = conn.open_bi().await.unwrap(); + (conn, send, recv) +} + +const LIMIT: usize = 64 * 1024; + +/// Sends `Hello` and reads the responder's `HelloAck`. +async fn exchange_hellos( + send: &mut SendStream, + recv: &mut RecvStream, + version: u16, + network_id: NetworkId, + nonce: [u8; 16], +) -> HelloAck { + let hello = Hello { + version, + network_id: *network_id.as_bytes(), + nonce, + }; + write_frame(send, &encode(&hello).unwrap(), LIMIT) + .await + .unwrap(); + decode(&read_frame(recv, LIMIT).await.unwrap()).unwrap() +} + +#[tokio::test] +async fn an_attacker_who_knows_the_public_network_id_is_still_rejected() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("closed-network"); + + let victim = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = victim.agent.subscribe(); + let network_id = victim.agent.join_network(&name, &secret).await.unwrap(); + + // The attacker knows the address and the correct *public* network id. It + // does not know the secret, so it cannot compute a valid proof. + let attacker = raw_endpoint().await; + let (conn, mut send, mut recv) = + open_control_stream(&attacker, victim.agent.local_addr()).await; + + let _ack = exchange_hellos( + &mut send, + &mut recv, + PROTOCOL_VERSION, + network_id, + [9u8; 16], + ) + .await; + let bogus = AuthProof { proof: [0xAB; 32] }; + write_frame(&mut send, &encode(&bogus).unwrap(), LIMIT) + .await + .unwrap(); + + // The responder must not reveal a proof of its own and must reject us. + let reply = read_frame(&mut recv, LIMIT).await; + assert!( + reply.is_err(), + "the agent must not answer an invalid proof, got {reply:?}" + ); + + let reason = wait_event(&mut events, |event| match event { + Event::HandshakeRejected { reason, .. } => Some(reason.clone()), + _ => None, + }) + .await; + assert!( + reason.contains("authentication failed"), + "unexpected reason: {reason}" + ); + + // The victim is unharmed: no session, and the network is still running. + let status = victim.agent.network_status(network_id).await.unwrap(); + assert!(status.peers.is_empty()); + assert_eq!(status.metrics.sessions_established, 0); + + conn.close(0u32.into(), b"done"); + attacker.close().await; + victim.agent.shutdown().await; +} + +#[tokio::test] +async fn a_wrong_secret_under_the_same_name_lands_in_a_different_space() { + let discovery = SharedMemoryDiscovery::new(); + let name = NetworkName::new("same-name").unwrap(); + + let good = TestAgent::spawn(&discovery).await.unwrap(); + let bad = TestAgent::spawn(&discovery).await.unwrap(); + + let good_id = good + .agent + .join_network(&name, &NetworkSecret::generate()) + .await + .unwrap(); + let bad_id = bad + .agent + .join_network(&name, &NetworkSecret::generate()) + .await + .unwrap(); + assert_ne!(good_id, bad_id); + + // Even with discovery wired together, the two never form a session. + // + // This test only shows that discovery separated them; the authoritative + // check that the secret itself gates membership is + // `an_attacker_who_knows_the_public_network_id_is_still_rejected`. + settle().await; + let status = good.agent.network_status(good_id).await.unwrap(); + assert!(status.peers.is_empty()); + + good.agent.shutdown().await; + bad.agent.shutdown().await; +} + +#[tokio::test] +async fn an_unsupported_protocol_version_is_rejected() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("versioned"); + + let victim = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = victim.agent.subscribe(); + let network_id = victim.agent.join_network(&name, &secret).await.unwrap(); + + let attacker = raw_endpoint().await; + let (conn, mut send, mut recv) = + open_control_stream(&attacker, victim.agent.local_addr()).await; + + let hello = Hello { + version: PROTOCOL_VERSION + 7, + network_id: *network_id.as_bytes(), + nonce: [1u8; 16], + }; + write_frame(&mut send, &encode(&hello).unwrap(), LIMIT) + .await + .unwrap(); + assert!(read_frame(&mut recv, LIMIT).await.is_err()); + + let reason = wait_event(&mut events, |event| match event { + Event::HandshakeRejected { reason, .. } => Some(reason.clone()), + _ => None, + }) + .await; + assert!(reason.contains("version"), "unexpected reason: {reason}"); + + // The agent is still alive and usable afterwards. + assert!(victim.agent.network_status(network_id).await.is_ok()); + + conn.close(0u32.into(), b"done"); + attacker.close().await; + victim.agent.shutdown().await; +} + +#[tokio::test] +async fn a_control_message_before_authentication_is_rejected() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("no-early-messages"); + + let victim = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = victim.agent.subscribe(); + let network_id = victim.agent.join_network(&name, &secret).await.unwrap(); + + let attacker = raw_endpoint().await; + let (conn, mut send, mut recv) = + open_control_stream(&attacker, victim.agent.local_addr()).await; + + // A perfectly well formed control message, sent where a Hello belongs. + let envelope = Envelope { + network_id: *network_id.as_bytes(), + message: ControlMessage::Ping { + seq: 1, + payload: b"too early".to_vec(), + }, + }; + write_frame(&mut send, &encode(&envelope).unwrap(), LIMIT) + .await + .unwrap(); + assert!(read_frame(&mut recv, LIMIT).await.is_err()); + + wait_event(&mut events, |event| match event { + Event::HandshakeRejected { .. } => Some(()), + _ => None, + }) + .await; + + let status = victim.agent.network_status(network_id).await.unwrap(); + assert!(status.peers.is_empty()); + + conn.close(0u32.into(), b"done"); + attacker.close().await; + victim.agent.shutdown().await; +} + +#[tokio::test] +async fn an_oversized_frame_is_rejected_before_it_is_allocated() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("bounded-frames"); + + let victim = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = victim.agent.subscribe(); + let network_id = victim.agent.join_network(&name, &secret).await.unwrap(); + + let attacker = raw_endpoint().await; + let (conn, mut send, mut recv) = + open_control_stream(&attacker, victim.agent.local_addr()).await; + + // Announce four gigabytes and then send nothing. The agent must reject the + // header instead of allocating the buffer. + send.write_all(&u32::MAX.to_be_bytes()).await.unwrap(); + assert!(read_frame(&mut recv, LIMIT).await.is_err()); + + wait_event(&mut events, |event| match event { + Event::HandshakeRejected { reason, .. } if reason.contains("exceeds") => Some(()), + _ => None, + }) + .await; + + // Still serving other work. + assert!(victim.agent.network_status(network_id).await.is_ok()); + + conn.close(0u32.into(), b"done"); + attacker.close().await; + victim.agent.shutdown().await; +} + +#[tokio::test] +async fn a_proof_cannot_be_replayed_on_another_connection() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("channel-bound"); + + let victim = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = victim.agent.subscribe(); + let network_id = victim.agent.join_network(&name, &secret).await.unwrap(); + + // A legitimate member: it knows the secret and can compute real proofs. + let keys = NetworkKeys::derive(&name, &secret); + let auth_key = test_support::auth_key(&keys); + let member = raw_endpoint().await; + let local = *member.id().as_bytes(); + let remote = *victim.agent.endpoint_id().as_bytes(); + let network_bytes = *network_id.as_bytes(); + + // Connection one: a genuine, successful handshake. + let (conn1, mut send1, mut recv1) = + open_control_stream(&member, victim.agent.local_addr()).await; + let nonce_i = [11u8; 16]; + let ack1 = exchange_hellos( + &mut send1, + &mut recv1, + PROTOCOL_VERSION, + network_id, + nonce_i, + ) + .await; + let cb1 = test_support::channel_binding(&conn1, &network_bytes).unwrap(); + let genuine = test_support::compute_proof( + &auth_key, + ROLE_INITIATOR, + PROTOCOL_VERSION, + &network_bytes, + &local, + &remote, + &cb1, + &nonce_i, + &ack1.nonce, + ); + write_frame( + &mut send1, + &encode(&AuthProof { proof: genuine }).unwrap(), + LIMIT, + ) + .await + .unwrap(); + let their_proof: AuthProof = decode(&read_frame(&mut recv1, LIMIT).await.unwrap()).unwrap(); + + // The responder proved membership too, and it used the responder role. + let expected = test_support::compute_proof( + &auth_key, + ROLE_RESPONDER, + PROTOCOL_VERSION, + &network_bytes, + &local, + &remote, + &cb1, + &nonce_i, + &ack1.nonce, + ); + assert_eq!(their_proof.proof, expected, "responder proof must verify"); + assert_ne!( + their_proof.proof, genuine, + "roles must not produce the same proof, or it could be reflected" + ); + + // Connection two: replay the captured proof verbatim. The TLS exporter + // differs per connection, so the proof no longer matches. + let (conn2, mut send2, mut recv2) = + open_control_stream(&member, victim.agent.local_addr()).await; + let ack2 = exchange_hellos( + &mut send2, + &mut recv2, + PROTOCOL_VERSION, + network_id, + nonce_i, + ) + .await; + let cb2 = test_support::channel_binding(&conn2, &network_bytes).unwrap(); + assert_ne!(cb1, cb2, "channel binding must differ between connections"); + let _ = ack2; + + write_frame( + &mut send2, + &encode(&AuthProof { proof: genuine }).unwrap(), + LIMIT, + ) + .await + .unwrap(); + assert!( + read_frame(&mut recv2, LIMIT).await.is_err(), + "a replayed proof must be rejected" + ); + + let reason = wait_event(&mut events, |event| match event { + Event::HandshakeRejected { reason, .. } if reason.contains("authentication failed") => { + Some(reason.clone()) + } + _ => None, + }) + .await; + assert!(reason.contains("authentication failed")); + + conn1.close(0u32.into(), b"done"); + conn2.close(0u32.into(), b"done"); + member.close().await; + victim.agent.shutdown().await; +} + +#[tokio::test] +async fn a_hello_for_an_inactive_network_is_rejected() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("active-only"); + + let victim = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = victim.agent.subscribe(); + victim.agent.join_network(&name, &secret).await.unwrap(); + + // A different, perfectly valid network space the victim is not in. + let other = NetworkKeys::derive( + &NetworkName::new("somewhere-else").unwrap(), + &NetworkSecret::generate(), + ); + + let attacker = raw_endpoint().await; + let (conn, mut send, mut recv) = + open_control_stream(&attacker, victim.agent.local_addr()).await; + let hello = Hello { + version: PROTOCOL_VERSION, + network_id: *other.network_id().as_bytes(), + nonce: [3u8; 16], + }; + write_frame(&mut send, &encode(&hello).unwrap(), LIMIT) + .await + .unwrap(); + assert!(read_frame(&mut recv, LIMIT).await.is_err()); + + let reason = wait_event(&mut events, |event| match event { + Event::HandshakeRejected { + network, reason, .. + } => { + // Before a successful handshake the claimed network is unverified, + // so it must not be reported as fact. + assert!(network.is_none()); + Some(reason.clone()) + } + _ => None, + }) + .await; + assert!(reason.contains("unknown"), "unexpected reason: {reason}"); + + conn.close(0u32.into(), b"done"); + attacker.close().await; + victim.agent.shutdown().await; +} diff --git a/tests/cache_and_state.rs b/tests/cache_and_state.rs new file mode 100644 index 0000000..65197f9 --- /dev/null +++ b/tests/cache_and_state.rs @@ -0,0 +1,203 @@ +//! Scenario 7: the disposable cache and the mandatory state store. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::io::Write; + +use common::{TestAgent, config_with, local_config, network, wait_for_peers}; +use tsunagi::config::StoragePaths; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::storage::CacheOutcome; +use tsunagi::{Agent, Error}; + +/// Overwrites a file with bytes that are definitely not a SQLite database. +fn corrupt(path: &std::path::Path) { + let mut file = std::fs::File::create(path).unwrap(); + file.write_all(&[0x7f; 8192]).unwrap(); + file.sync_all().unwrap(); +} + +#[tokio::test] +async fn a_missing_cache_is_recreated_and_does_not_block_connecting() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("missing-cache"); + + let peer = TestAgent::spawn(&discovery).await.unwrap(); + let subject = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); + subject.agent.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&peer.agent, network_id, 1).await; + + let dir = subject.stop().await; + let paths = StoragePaths::under(dir.path()); + std::fs::remove_dir_all(&paths.cache_dir).unwrap(); + assert!(!paths.cache_db().exists()); + + let restarted = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + assert_eq!( + restarted.status().await.unwrap().cache_outcome, + CacheOutcome::Created + ); + wait_for_peers(&restarted, network_id, 1).await; + + restarted.shutdown().await; + peer.agent.shutdown().await; + drop(restarted); + drop(dir); +} + +#[tokio::test] +async fn a_corrupt_cache_is_discarded_and_does_not_block_connecting() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("corrupt-cache"); + + let peer = TestAgent::spawn(&discovery).await.unwrap(); + let subject = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); + subject.agent.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&peer.agent, network_id, 1).await; + + let device_id = subject.agent.endpoint_id(); + let dir = subject.stop().await; + let paths = StoragePaths::under(dir.path()); + corrupt(&paths.cache_db()); + + let restarted = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + let status = restarted.status().await.unwrap(); + assert!( + matches!(status.cache_outcome, CacheOutcome::Reset(_)), + "expected the cache to be discarded, got {:?}", + status.cache_outcome + ); + assert!(status.cache_healthy); + assert_eq!( + restarted.endpoint_id(), + device_id, + "a bad cache must not touch the identity" + ); + wait_for_peers(&restarted, network_id, 1).await; + + restarted.shutdown().await; + peer.agent.shutdown().await; + drop(restarted); + drop(dir); +} + +#[tokio::test] +async fn a_stale_cache_does_not_prevent_connecting_through_discovery() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("stale-cache"); + + let peer = TestAgent::spawn(&discovery).await.unwrap(); + let subject = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); + subject.agent.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&peer.agent, network_id, 1).await; + + // Stop both. When they come back they bind new ports, so every cached + // address hint is stale, and only fresh discovery can bridge the gap. + let peer_dir = peer.stop().await; + let subject_dir = subject.stop().await; + assert!( + StoragePaths::under(subject_dir.path()).cache_db().exists(), + "hints were written, so the cache is genuinely stale now" + ); + + let peer_again = Agent::spawn(config_with(peer_dir.path(), &discovery)) + .await + .unwrap(); + let subject_again = Agent::spawn(config_with(subject_dir.path(), &discovery)) + .await + .unwrap(); + + wait_for_peers(&subject_again, network_id, 1).await; + wait_for_peers(&peer_again, network_id, 1).await; + + peer_again.shutdown().await; + subject_again.shutdown().await; + drop(peer_again); + drop(subject_again); + drop(peer_dir); + drop(subject_dir); +} + +#[tokio::test] +async fn a_corrupt_state_store_is_an_error_and_never_a_fresh_identity() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("corrupt-state"); + + let subject = TestAgent::spawn(&discovery).await.unwrap(); + let device_id = subject.agent.endpoint_id(); + subject.agent.join_network(&name, &secret).await.unwrap(); + let dir = subject.stop().await; + + let paths = StoragePaths::under(dir.path()); + corrupt(&paths.state_db()); + + let result = Agent::spawn(config_with(dir.path(), &discovery)).await; + match result { + Err(Error::StateCorrupted { path, reason }) => { + assert_eq!(path, paths.state_db()); + assert!(!reason.is_empty()); + } + Err(other) => panic!("expected StateCorrupted, got {other:?}"), + Ok(agent) => { + let new_id = agent.endpoint_id(); + agent.shutdown().await; + panic!( + "a corrupt state store must not yield a working agent (id {new_id}) — the previous identity was {device_id}" + ); + } + } + + drop(dir); +} + +#[tokio::test] +async fn a_state_store_from_a_newer_build_is_refused() { + let dir = tempfile::TempDir::new().unwrap(); + let paths = StoragePaths::under(dir.path()); + std::fs::create_dir_all(&paths.state_dir).unwrap(); + + { + let conn = rusqlite::Connection::open(paths.state_db()).unwrap(); + conn.pragma_update(None, "user_version", 999i64).unwrap(); + } + + let result = Agent::spawn(local_config(dir.path())).await; + assert!( + matches!(result, Err(Error::UnsupportedSchema { found: 999, .. })), + "expected UnsupportedSchema" + ); +} + +#[tokio::test] +async fn secrets_never_appear_in_status_or_debug_output() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("no-leaks"); + + let agent = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = agent.agent.join_network(&name, &secret).await.unwrap(); + + let status = agent.agent.status().await.unwrap(); + let rendered = format!("{status:?}"); + let encoded = secret.encode(); + assert!(!rendered.contains(encoded.as_str())); + assert!(rendered.contains(&network_id.to_string()) || rendered.contains("NetworkId")); + + let network_status = agent.agent.network_status(network_id).await.unwrap(); + assert!(!format!("{network_status:?}").contains(encoded.as_str())); + + let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret); + let keys_debug = format!("{keys:?}"); + assert!(keys_debug.contains("")); + assert!(!keys_debug.contains(encoded.as_str())); + + agent.agent.shutdown().await; +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..7d3f903 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,185 @@ +//! Shared helpers for the integration tests. +//! +//! Every test uses real iroh endpoints on loopback, its own temporary SQLite +//! files and independent agent instances. Only discovery is substituted; iroh, +//! the handshake, message passing and persistent storage are not. +//! +//! Synchronisation is always "wait for a specific event or condition, under one +//! overall deadline", never a fixed multi-second sleep. + +#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::future::Future; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tempfile::TempDir; +use tokio::sync::broadcast::error::RecvError; +use tsunagi::agent::Event; +use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkName, NetworkSecret}; +use tsunagi::{Agent, Result}; + +/// Installs a tracing subscriber when `TSUNAGI_TEST_LOG` is set. +/// +/// The library never installs a global subscriber itself; tests opt in. +pub fn init_tracing() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + if let Ok(filter) = std::env::var("TSUNAGI_TEST_LOG") { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(filter)) + .with_writer(std::io::stderr) + .try_init(); + } + }); +} + +/// Overall deadline for anything a test waits on. +pub const DEADLINE: Duration = Duration::from_secs(30); + +/// How often condition polling re-checks. Never used as the primary sync. +const POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Builds a fully local configuration rooted at `dir`. +/// +/// Loopback binding with a dynamic port, no relay, no address lookup and no +/// port mapping, so the suite needs neither the internet nor privileges. +/// Timeouts are shortened so that failure paths finish quickly. +pub fn local_config(dir: &Path) -> AgentConfig { + let limits = tsunagi::Limits { + dial_timeout: Duration::from_millis(1500), + handshake_timeout: Duration::from_secs(5), + ..Default::default() + }; + AgentConfig::new(StoragePaths::under(dir)) + .with_transport(TransportPolicy::LocalOnly) + .with_loopback_bind() + .with_discovery_interval(Duration::from_millis(150)) + .with_limits(limits) +} + +/// Builds a local configuration wired to a shared in-memory discovery table. +pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig { + local_config(dir).with_discovery(Arc::new(discovery.clone())) +} + +/// A temporary directory plus the agent running on it. +pub struct TestAgent { + pub dir: TempDir, + pub agent: Agent, +} + +impl TestAgent { + /// Starts an agent on a fresh temporary directory. + pub async fn spawn(discovery: &SharedMemoryDiscovery) -> Result { + init_tracing(); + let dir = TempDir::new().expect("temp dir"); + let agent = Agent::spawn(config_with(dir.path(), discovery)).await?; + Ok(Self { dir, agent }) + } + + /// Starts an agent with a caller-supplied configuration on a fresh dir. + pub async fn spawn_with( + build: impl FnOnce(AgentConfig) -> AgentConfig, + discovery: &SharedMemoryDiscovery, + ) -> Result { + init_tracing(); + let dir = TempDir::new().expect("temp dir"); + let agent = Agent::spawn(build(config_with(dir.path(), discovery))).await?; + Ok(Self { dir, agent }) + } + + /// Stops the agent and returns the directory so it can be reopened. + pub async fn stop(self) -> TempDir { + self.agent.shutdown().await; + self.dir + } +} + +/// A network name and a fresh high-entropy secret. +pub fn network(name: &str) -> (NetworkName, NetworkSecret) { + ( + NetworkName::new(name).expect("valid network name"), + NetworkSecret::generate(), + ) +} + +/// Waits for an event matching `predicate`, under the global deadline. +pub async fn wait_event( + rx: &mut tokio::sync::broadcast::Receiver, + predicate: impl Fn(&Event) -> Option, +) -> T { + let deadline = Instant::now() + DEADLINE; + let mut seen: Vec = Vec::new(); + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + assert!(!remaining.is_zero(), "timed out waiting for an event"); + + match tokio::time::timeout(remaining, rx.recv()).await { + Ok(Ok(event)) => { + if let Some(value) = predicate(&event) { + return value; + } + if seen.len() < 12 { + let label = format!("{event:?}"); + seen.push(label.chars().take(70).collect()); + } + } + Ok(Err(RecvError::Lagged(skipped))) => { + panic!("event subscriber lagged, missed {skipped} events; first seen: {seen:#?}"); + } + Ok(Err(RecvError::Closed)) => panic!("event channel closed while waiting"), + Err(_) => panic!("timed out waiting for an event"), + } + } +} + +/// Polls an async condition until it returns `Some`, under the global deadline. +pub async fn wait_until(what: &str, mut probe: F) -> T +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let deadline = Instant::now() + DEADLINE; + loop { + if let Some(value) = probe().await { + return value; + } + assert!( + Instant::now() < deadline, + "timed out waiting for condition: {what}" + ); + tokio::time::sleep(POLL_INTERVAL).await; + } +} + +/// Lets a few discovery rounds pass. +/// +/// Only ever used before asserting that something did **not** happen; waiting +/// for success always goes through [`wait_event`] or [`wait_until`]. +pub async fn settle() { + tokio::time::sleep(Duration::from_millis(900)).await; +} + +/// Waits until `agent` has `count` authenticated peers in `network`. +pub async fn wait_for_peers( + agent: &Agent, + network: tsunagi::NetworkId, + count: usize, +) -> Vec { + wait_until(&format!("{count} peers in {network}"), || async move { + let status = agent.network_status(network).await.ok()?; + if status.peers.len() >= count { + Some(status.connected_peers()) + } else { + None + } + }) + .await +} diff --git a/tests/discovery.rs b/tests/discovery.rs new file mode 100644 index 0000000..c39cdaf --- /dev/null +++ b/tests/discovery.rs @@ -0,0 +1,141 @@ +//! Discovery backends: static bootstrap candidates, composition, and the fact +//! that discovery only ever supplies *candidates*. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::sync::Arc; + +use common::{TestAgent, local_config, network, settle, wait_for_peers}; +use tsunagi::Agent; +use tsunagi::discovery::{ + CandidateSource, CompositeDiscovery, NetworkDiscovery, SharedMemoryDiscovery, StaticBootstrap, +}; +use tsunagi::identity::NetworkKeys; + +#[tokio::test] +async fn a_static_bootstrap_candidate_is_enough_to_join() { + let (name, secret) = network("bootstrap-only"); + + // The listener runs with no discovery at all: it only accepts. + let listener_dir = tempfile::TempDir::new().unwrap(); + let listener = Agent::spawn(local_config(listener_dir.path())) + .await + .unwrap(); + let network_id = listener.join_network(&name, &secret).await.unwrap(); + + // The joiner is handed the listener's iroh id and addresses up front, which + // is exactly what a static bootstrap entry is. + let bootstrap = Arc::new(StaticBootstrap::new([listener.local_addr()])); + let joiner_dir = tempfile::TempDir::new().unwrap(); + let joiner = Agent::spawn(local_config(joiner_dir.path()).with_discovery(bootstrap)) + .await + .unwrap(); + joiner.join_network(&name, &secret).await.unwrap(); + + wait_for_peers(&joiner, network_id, 1).await; + wait_for_peers(&listener, network_id, 1).await; + + let status = joiner.network_status(network_id).await.unwrap(); + assert_eq!( + status + .candidates + .iter() + .find(|candidate| candidate.endpoint_id == listener.endpoint_id()) + .map(|candidate| candidate.source), + Some(CandidateSource::Bootstrap) + ); + + joiner.shutdown().await; + listener.shutdown().await; + drop(joiner); + drop(listener); + drop(joiner_dir); + drop(listener_dir); +} + +#[tokio::test] +async fn a_composite_backend_merges_its_sources() { + let (name, secret) = network("composite"); + let keys = NetworkKeys::derive(&name, &secret); + + let shared = SharedMemoryDiscovery::new(); + let via_shared = TestAgent::spawn(&shared).await.unwrap(); + let network_id = via_shared.agent.join_network(&name, &secret).await.unwrap(); + + let bootstrap_dir = tempfile::TempDir::new().unwrap(); + let via_bootstrap = Agent::spawn(local_config(bootstrap_dir.path())) + .await + .unwrap(); + via_bootstrap.join_network(&name, &secret).await.unwrap(); + + // One backend knows the bootstrap peer, the other knows the shared-table + // peer. Composed, the joiner reaches both. + let composite = Arc::new(CompositeDiscovery::new([ + Arc::new(StaticBootstrap::new([via_bootstrap.local_addr()])) as Arc, + Arc::new(shared.clone()) as Arc, + ])); + let joiner_dir = tempfile::TempDir::new().unwrap(); + let joiner = Agent::spawn(local_config(joiner_dir.path()).with_discovery(composite)) + .await + .unwrap(); + joiner.join_network(&name, &secret).await.unwrap(); + + wait_for_peers(&joiner, network_id, 2).await; + + // Publishing went to the shared backend, so the other agent finds us too. + assert!(shared.len(&keys.discovery_key()) >= 2); + + joiner.shutdown().await; + via_bootstrap.shutdown().await; + via_shared.agent.shutdown().await; + drop(joiner); + drop(via_bootstrap); + drop(joiner_dir); + drop(bootstrap_dir); +} + +#[tokio::test] +async fn discovery_entries_are_withdrawn_when_a_network_stops() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("withdrawn"); + let keys = NetworkKeys::derive(&name, &secret); + + let agent = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = agent.agent.join_network(&name, &secret).await.unwrap(); + + settle().await; + assert_eq!(discovery.len(&keys.discovery_key()), 1); + + agent.agent.deactivate_network(network_id).await.unwrap(); + assert!(discovery.is_empty(&keys.discovery_key())); + + agent.agent.shutdown().await; +} + +#[tokio::test] +async fn forgetting_a_network_removes_it_from_the_state_store() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("forgettable"); + + let agent = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = agent.agent.join_network(&name, &secret).await.unwrap(); + assert_eq!(agent.agent.list_networks().await.unwrap().len(), 1); + + agent.agent.forget_network(network_id).await.unwrap(); + assert!(agent.agent.list_networks().await.unwrap().is_empty()); + assert!(!agent.agent.is_active(network_id).await); + + let dir = agent.stop().await; + let restarted = + Agent::spawn(local_config(dir.path()).with_discovery(Arc::new(discovery.clone()))) + .await + .unwrap(); + assert!(restarted.list_networks().await.unwrap().is_empty()); + assert_eq!(restarted.status().await.unwrap().networks.len(), 0); + + restarted.shutdown().await; + drop(restarted); + drop(dir); +} diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs new file mode 100644 index 0000000..2eb61a8 --- /dev/null +++ b/tests/end_to_end.rs @@ -0,0 +1,65 @@ +//! The full vertical slice: persistent identity, network space, discovery, +//! real iroh connections, authentication and a control message exchange. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use common::{TestAgent, network, wait_event, wait_for_peers}; +use tsunagi::agent::Event; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::proto::ControlMessage; + +#[tokio::test] +async fn two_agents_authenticate_and_exchange_messages() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("vertical-slice"); + + let a = TestAgent::spawn(&discovery).await.unwrap(); + let b = TestAgent::spawn(&discovery).await.unwrap(); + + let mut events_a = a.agent.subscribe(); + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + let same_id = b.agent.join_network(&name, &secret).await.unwrap(); + assert_eq!( + network_id, same_id, + "the same name and secret must produce the same network space" + ); + + let peers = wait_for_peers(&a.agent, network_id, 1).await; + assert_eq!(peers, vec![b.agent.endpoint_id()]); + + // The exchange itself: a ping must come back as a matching pong. + a.agent + .send( + network_id, + b.agent.endpoint_id(), + ControlMessage::Ping { + seq: 42, + payload: b"vertical".to_vec(), + }, + ) + .await + .unwrap(); + + let payload = wait_event(&mut events_a, |event| match event { + Event::MessageReceived { + network, + peer, + message: ControlMessage::Pong { seq: 42, payload }, + } if *network == network_id && *peer == b.agent.endpoint_id() => Some(payload.clone()), + _ => None, + }) + .await; + assert_eq!(payload, b"vertical".to_vec()); + + // Both sides announce a hostname over the authenticated session. + let status = a.agent.network_status(network_id).await.unwrap(); + let peer = &status.peers[0]; + assert_eq!(peer.hostname.as_deref(), Some(b.agent.hostname())); + assert!(peer.transport != tsunagi::net::TransportKind::Unknown); + assert!(peer.rtt.is_some(), "a verified path must report an RTT"); + + a.agent.shutdown().await; + b.agent.shutdown().await; +} diff --git a/tests/identity.rs b/tests/identity.rs new file mode 100644 index 0000000..a72dec7 --- /dev/null +++ b/tests/identity.rs @@ -0,0 +1,175 @@ +//! Scenario 1: deterministic network identity. +//! +//! The same name and secret must yield the same network space on different +//! devices, and nothing else — hostname, device key, restart — may change it. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use common::{TestAgent, config_with, network}; +use tsunagi::Agent; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; + +#[test] +fn derivation_is_a_pure_function_of_name_and_secret() { + let name = NetworkName::new("home").unwrap(); + let other = NetworkName::new("Home").unwrap(); + let secret = NetworkSecret::generate(); + let other_secret = NetworkSecret::generate(); + + let a = NetworkKeys::derive(&name, &secret); + let b = NetworkKeys::derive(&name, &secret); + assert_eq!(a.network_id(), b.network_id()); + assert_eq!(a.discovery_key(), b.discovery_key()); + assert_eq!(a.descriptor(), b.descriptor()); + + // A different name is a different space. Names are used verbatim, so case + // matters. + assert_ne!( + a.network_id(), + NetworkKeys::derive(&other, &secret).network_id() + ); + // A different secret is a different space. + assert_ne!( + a.network_id(), + NetworkKeys::derive(&name, &other_secret).network_id() + ); + // Separated key material: the discovery key is not the network id. + assert_ne!(a.network_id().as_bytes(), a.discovery_key().as_bytes()); +} + +#[test] +fn descriptor_carries_no_creator_time_or_secret() { + let name = NetworkName::new("shared").unwrap(); + let secret = NetworkSecret::generate(); + let first = NetworkKeys::derive(&name, &secret).descriptor(); + let second = NetworkKeys::derive(&name, &secret).descriptor(); + + // Two independently built descriptors are byte identical: no random + // creator id, no creation timestamp, no owner signature. + assert_eq!(first.to_canonical_bytes(), second.to_canonical_bytes()); + + let encoded = first.to_canonical_bytes(); + let secret_bytes = secret.encode(); + assert!( + !encoded + .windows(secret_bytes.len()) + .any(|window| window == secret_bytes.as_bytes()), + "the secret must never appear in the public descriptor" + ); +} + +#[test] +fn names_are_validated_not_silently_normalised() { + assert!(NetworkName::new("").is_err()); + assert!(NetworkName::new(" home").is_err(), "must not be trimmed"); + assert!(NetworkName::new("home ").is_err(), "must not be trimmed"); + assert!(NetworkName::new("ho\nme").is_err()); + assert!(NetworkName::new("a".repeat(65)).is_err()); + assert_eq!(NetworkName::new("home").unwrap().as_str(), "home"); +} + +#[test] +fn secrets_are_not_truncated_or_normalised() { + let secret = NetworkSecret::generate(); + let text = secret.encode(); + let round_tripped = NetworkSecret::decode(&text).unwrap(); + assert_eq!(secret, round_tripped); + + // Short secrets are rejected rather than stretched. + assert!(NetworkSecret::from_bytes(vec![7u8; 15]).is_err()); + assert!(NetworkSecret::from_bytes(vec![7u8; 16]).is_ok()); + + // Debug output must not leak the secret. + let rendered = format!("{secret:?}"); + assert_eq!(rendered, "NetworkSecret()"); +} + +#[tokio::test] +async fn different_devices_and_hostnames_agree_on_the_network_id() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("agreement"); + + let a = TestAgent::spawn_with(|cfg| cfg.with_hostname("alpha"), &discovery) + .await + .unwrap(); + let b = TestAgent::spawn_with(|cfg| cfg.with_hostname("beta"), &discovery) + .await + .unwrap(); + + assert_ne!( + a.agent.endpoint_id(), + b.agent.endpoint_id(), + "different devices must have different endpoint ids" + ); + assert_eq!(a.agent.hostname(), "alpha"); + assert_eq!(b.agent.hostname(), "beta"); + + let id_a = a.agent.join_network(&name, &secret).await.unwrap(); + let id_b = b.agent.join_network(&name, &secret).await.unwrap(); + assert_eq!(id_a, id_b); + + a.agent.shutdown().await; + b.agent.shutdown().await; +} + +#[tokio::test] +async fn restart_keeps_the_device_and_network_identity() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("stable"); + + let first = TestAgent::spawn(&discovery).await.unwrap(); + let device_id = first.agent.endpoint_id(); + let network_id = first.agent.join_network(&name, &secret).await.unwrap(); + let dir = first.stop().await; + + let reopened = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + assert_eq!( + reopened.endpoint_id(), + device_id, + "restarting must not mint a new peer" + ); + let networks = reopened.list_networks().await.unwrap(); + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].network_id, network_id); + assert!(networks[0].active, "auto-start networks come back up"); + + reopened.shutdown().await; + drop(reopened); + drop(dir); +} + +#[tokio::test] +async fn changing_the_secret_keeps_the_device_identity() { + let discovery = SharedMemoryDiscovery::new(); + let dir = tempfile::TempDir::new().unwrap(); + let agent = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + let device_id = agent.endpoint_id(); + + let name = NetworkName::new("rotating").unwrap(); + let old = agent + .join_network(&name, &NetworkSecret::generate()) + .await + .unwrap(); + let new = agent + .join_network(&name, &NetworkSecret::generate()) + .await + .unwrap(); + + assert_ne!(old, new, "a new secret is a new network space"); + assert_eq!( + agent.endpoint_id(), + device_id, + "rotating the network secret must not change the persistent iroh id" + ); + + agent.shutdown().await; + drop(agent); + drop(dir); +} diff --git a/tests/multi_peer.rs b/tests/multi_peer.rs new file mode 100644 index 0000000..14d4723 --- /dev/null +++ b/tests/multi_peer.rs @@ -0,0 +1,172 @@ +//! Scenario 2: several agents find each other, authenticate for real and +//! exchange distinguishable messages. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::collections::HashSet; +use std::sync::Arc; + +use common::{TestAgent, network, wait_event, wait_for_peers, wait_until}; +use tsunagi::agent::Event; +use tsunagi::dataplane::TestCapabilityPlugin; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::proto::ControlMessage; + +#[tokio::test] +async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("mesh-of-four"); + + let mut agents = Vec::new(); + for index in 0..4 { + let hostname = format!("host-{index}"); + agents.push( + TestAgent::spawn_with(move |cfg| cfg.with_hostname(hostname), &discovery) + .await + .unwrap(), + ); + } + + let mut network_ids = HashSet::new(); + for agent in &agents { + network_ids.insert(agent.agent.join_network(&name, &secret).await.unwrap()); + } + assert_eq!(network_ids.len(), 1, "one deterministic network space"); + let network_id = network_ids.into_iter().next().unwrap(); + + // Full mesh: every agent must end up with the other three. + for agent in &agents { + wait_for_peers(&agent.agent, network_id, 3).await; + } + + // Each peer announced its own hostname, so sessions are distinguishable. + let status = agents[0].agent.network_status(network_id).await.unwrap(); + let hostnames: HashSet = status + .peers + .iter() + .filter_map(|peer| peer.hostname.clone()) + .collect(); + assert_eq!( + hostnames.len(), + 3, + "three distinct hostnames: {hostnames:?}" + ); + + // Distinguishable request/response: each peer echoes its own sequence. + let mut events = agents[0].agent.subscribe(); + for (index, peer) in agents.iter().skip(1).enumerate() { + agents[0] + .agent + .send( + network_id, + peer.agent.endpoint_id(), + ControlMessage::Ping { + seq: index as u64 + 1, + payload: format!("to-{index}").into_bytes(), + }, + ) + .await + .unwrap(); + } + + let mut seen = HashSet::new(); + while seen.len() < 3 { + let (peer, seq, payload) = wait_event(&mut events, |event| match event { + Event::MessageReceived { + network, + peer, + message: ControlMessage::Pong { seq, payload }, + } if *network == network_id => Some((*peer, *seq, payload.clone())), + _ => None, + }) + .await; + assert_eq!(payload, format!("to-{}", seq - 1).into_bytes()); + seen.insert(peer); + } + assert_eq!(seen.len(), 3); + + for agent in agents { + agent.agent.shutdown().await; + } +} + +#[tokio::test] +async fn a_late_joiner_is_picked_up_by_the_existing_members() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("late-joiner"); + + let first = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = first.agent.join_network(&name, &secret).await.unwrap(); + + // Nobody else is there yet. That is "nobody found so far", not proof that + // the network is empty, and the agent is ready regardless. + let status = first.agent.network_status(network_id).await.unwrap(); + assert!(status.peers.is_empty()); + + let second = TestAgent::spawn(&discovery).await.unwrap(); + second.agent.join_network(&name, &secret).await.unwrap(); + + wait_for_peers(&first.agent, network_id, 1).await; + wait_for_peers(&second.agent, network_id, 1).await; + + first.agent.shutdown().await; + second.agent.shutdown().await; +} + +#[tokio::test] +async fn opaque_plugin_capabilities_cross_the_control_plane() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("capabilities"); + + // An explicitly test-only capability: nothing here advertises WireGuard. + let plugin_a = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-a".to_vec())); + let plugin_b = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-b".to_vec())); + + let a = TestAgent::spawn_with( + { + let plugin = Arc::clone(&plugin_a); + move |cfg| cfg.with_plugin(plugin) + }, + &discovery, + ) + .await + .unwrap(); + let b = TestAgent::spawn_with( + { + let plugin = Arc::clone(&plugin_b); + move |cfg| cfg.with_plugin(plugin) + }, + &discovery, + ) + .await + .unwrap(); + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&a.agent, network_id, 1).await; + + let observed = wait_until("plugin a sees b's capability", || { + let plugin = Arc::clone(&plugin_a); + async move { + let seen = plugin.observed(); + if seen.is_empty() { None } else { Some(seen) } + } + }) + .await; + + let (seen_network, seen_peer, capability) = &observed[0]; + assert_eq!(*seen_network, network_id); + assert_eq!(*seen_peer, b.agent.endpoint_id()); + assert_eq!(capability.protocol, "test-ip"); + assert_eq!(capability.data, b"payload-b".to_vec()); + assert!(capability.enabled); + + // The core carried the payload without interpreting it. + let status = a.agent.network_status(network_id).await.unwrap(); + assert_eq!(status.peers[0].capabilities[0].data, b"payload-b".to_vec()); + + a.agent.shutdown().await; + b.agent.shutdown().await; +} diff --git a/tests/network_isolation.rs b/tests/network_isolation.rs new file mode 100644 index 0000000..c679b35 --- /dev/null +++ b/tests/network_isolation.rs @@ -0,0 +1,266 @@ +//! Scenario 4: one agent in two networks at once, with no bleed between them. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use common::{TestAgent, network, settle, wait_event, wait_for_peers}; +use iroh::endpoint::{PortmapperConfig, presets}; +use iroh::{Endpoint, RelayMode}; +use tsunagi::agent::Event; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::NetworkKeys; +use tsunagi::proto::handshake::ROLE_INITIATOR; +use tsunagi::proto::message::{ + ALPN, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, decode, encode, +}; +use tsunagi::proto::{read_frame, write_frame}; +use tsunagi::test_support; + +const LIMIT: usize = 64 * 1024; + +#[tokio::test] +async fn one_agent_in_two_networks_keeps_them_apart() { + let discovery = SharedMemoryDiscovery::new(); + let (name_a, secret_a) = network("alpha-net"); + let (name_b, secret_b) = network("beta-net"); + + let hub = TestAgent::spawn(&discovery).await.unwrap(); + let alpha_peer = TestAgent::spawn(&discovery).await.unwrap(); + let beta_peer = TestAgent::spawn(&discovery).await.unwrap(); + + let alpha = hub.agent.join_network(&name_a, &secret_a).await.unwrap(); + let beta = hub.agent.join_network(&name_b, &secret_b).await.unwrap(); + assert_ne!(alpha, beta); + + alpha_peer + .agent + .join_network(&name_a, &secret_a) + .await + .unwrap(); + beta_peer + .agent + .join_network(&name_b, &secret_b) + .await + .unwrap(); + + wait_for_peers(&hub.agent, alpha, 1).await; + wait_for_peers(&hub.agent, beta, 1).await; + + // Statuses do not mix: each network sees exactly its own peer. + let status_alpha = hub.agent.network_status(alpha).await.unwrap(); + let status_beta = hub.agent.network_status(beta).await.unwrap(); + assert_eq!( + status_alpha.connected_peers(), + vec![alpha_peer.agent.endpoint_id()] + ); + assert_eq!( + status_beta.connected_peers(), + vec![beta_peer.agent.endpoint_id()] + ); + + // Addressing a peer of one network through the other is refused locally. + let wrong = hub + .agent + .send( + alpha, + beta_peer.agent.endpoint_id(), + ControlMessage::Ping { + seq: 1, + payload: Vec::new(), + }, + ) + .await; + assert!(matches!(wrong, Err(tsunagi::Error::NoSuchPeer { .. }))); + + // Messages stay in their own network. + let mut events = hub.agent.subscribe(); + hub.agent + .broadcast( + alpha, + ControlMessage::Ping { + seq: 7, + payload: b"alpha-only".to_vec(), + }, + ) + .await + .unwrap(); + let from = wait_event(&mut events, |event| match event { + Event::MessageReceived { + network, + peer, + message: ControlMessage::Pong { seq: 7, payload }, + } if payload == b"alpha-only" => Some((*network, *peer)), + _ => None, + }) + .await; + assert_eq!(from, (alpha, alpha_peer.agent.endpoint_id())); + + let beta_status = hub.agent.network_status(beta).await.unwrap(); + assert_eq!( + beta_status.metrics.control_messages_received, + beta_status.peers[0].control_messages_received, + "beta's counters are its own" + ); + assert!( + beta_status + .peers + .iter() + .all(|peer| peer.endpoint_id != alpha_peer.agent.endpoint_id()) + ); + + // Deactivating one network must not disturb the other. + hub.agent.deactivate_network(alpha).await.unwrap(); + assert!(hub.agent.network_status(alpha).await.is_err()); + wait_for_peers(&hub.agent, beta, 1).await; + hub.agent + .send( + beta, + beta_peer.agent.endpoint_id(), + ControlMessage::Ping { + seq: 8, + payload: b"still-here".to_vec(), + }, + ) + .await + .unwrap(); + wait_event(&mut events, |event| match event { + Event::MessageReceived { + network, + message: ControlMessage::Pong { seq: 8, .. }, + .. + } if *network == beta => Some(()), + _ => None, + }) + .await; + + hub.agent.shutdown().await; + alpha_peer.agent.shutdown().await; + beta_peer.agent.shutdown().await; +} + +#[tokio::test] +async fn an_authenticated_session_cannot_speak_for_another_network() { + let discovery = SharedMemoryDiscovery::new(); + let (name_a, secret_a) = network("session-scope-a"); + let (name_b, secret_b) = network("session-scope-b"); + + let hub = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = hub.agent.subscribe(); + let alpha = hub.agent.join_network(&name_a, &secret_a).await.unwrap(); + let beta = hub.agent.join_network(&name_b, &secret_b).await.unwrap(); + + // A genuine member of `alpha`, driven by hand so it can misbehave. + let keys = NetworkKeys::derive(&name_a, &secret_a); + let auth_key = test_support::auth_key(&keys); + let member = Endpoint::builder(presets::Minimal) + .alpns(vec![ALPN.to_vec()]) + .relay_mode(RelayMode::Disabled) + .clear_address_lookup() + .portmapper_config(PortmapperConfig::Disabled) + .clear_ip_transports() + .bind_addr("127.0.0.1:0") + .unwrap() + .bind() + .await + .unwrap(); + + let conn = member.connect(hub.agent.local_addr(), ALPN).await.unwrap(); + let (mut send, mut recv) = conn.open_bi().await.unwrap(); + + let alpha_bytes = *alpha.as_bytes(); + let nonce_i = [5u8; 16]; + let hello = Hello { + version: PROTOCOL_VERSION, + network_id: alpha_bytes, + nonce: nonce_i, + }; + write_frame(&mut send, &encode(&hello).unwrap(), LIMIT) + .await + .unwrap(); + let ack: HelloAck = decode(&read_frame(&mut recv, LIMIT).await.unwrap()).unwrap(); + + let cb = test_support::channel_binding(&conn, &alpha_bytes).unwrap(); + let proof = test_support::compute_proof( + &auth_key, + ROLE_INITIATOR, + PROTOCOL_VERSION, + &alpha_bytes, + member.id().as_bytes(), + hub.agent.endpoint_id().as_bytes(), + &cb, + &nonce_i, + &ack.nonce, + ); + write_frame(&mut send, &encode(&AuthProof { proof }).unwrap(), LIMIT) + .await + .unwrap(); + let _responder_proof: AuthProof = decode(&read_frame(&mut recv, LIMIT).await.unwrap()).unwrap(); + + // Authenticated for alpha. Now try to speak for beta on the same session. + let smuggled = Envelope { + network_id: *beta.as_bytes(), + message: ControlMessage::Ping { + seq: 99, + payload: b"wrong network".to_vec(), + }, + }; + write_frame(&mut send, &encode(&smuggled).unwrap(), LIMIT) + .await + .unwrap(); + + let reason = wait_event(&mut events, |event| match event { + Event::ProtocolViolation { + network, reason, .. + } if *network == Some(alpha) => Some(reason.clone()), + _ => None, + }) + .await; + assert!( + reason.contains("network id does not match"), + "unexpected reason: {reason}" + ); + + // Beta saw nothing at all, and both networks keep running. + let beta_status = hub.agent.network_status(beta).await.unwrap(); + assert_eq!(beta_status.metrics.control_messages_received, 0); + assert!(beta_status.peers.is_empty()); + assert!(hub.agent.network_status(alpha).await.is_ok()); + + conn.close(0u32.into(), b"done"); + member.close().await; + hub.agent.shutdown().await; +} + +#[tokio::test] +async fn deactivating_one_network_leaves_the_agent_and_others_running() { + let discovery = SharedMemoryDiscovery::new(); + let (name_a, secret_a) = network("keep-a"); + let (name_b, secret_b) = network("keep-b"); + + let agent = TestAgent::spawn(&discovery).await.unwrap(); + let alpha = agent.agent.join_network(&name_a, &secret_a).await.unwrap(); + let beta = agent.agent.join_network(&name_b, &secret_b).await.unwrap(); + + agent.agent.deactivate_network(alpha).await.unwrap(); + settle().await; + + let status = agent.agent.status().await.unwrap(); + assert_eq!(status.networks.len(), 2, "both stay configured"); + assert_eq!( + status.network(&alpha).map(|net| net.state), + Some(tsunagi::agent::NetworkState::Inactive) + ); + assert_eq!( + status.network(&beta).map(|net| net.state), + Some(tsunagi::agent::NetworkState::Active) + ); + + // Deactivating twice is an error, not a crash. + assert!(agent.agent.deactivate_network(alpha).await.is_err()); + // And it can be brought back. + agent.agent.activate_network(alpha).await.unwrap(); + assert!(agent.agent.network_status(alpha).await.is_ok()); + + agent.agent.shutdown().await; +} diff --git a/tests/resilience.rs b/tests/resilience.rs new file mode 100644 index 0000000..c6ae634 --- /dev/null +++ b/tests/resilience.rs @@ -0,0 +1,345 @@ +//! Scenarios 8 and 10: unreachable participants, bounded retries, and the +//! agent lifecycle including state directory ownership. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::net::SocketAddr; +use std::time::Duration; + +use common::{TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers}; +use iroh::{EndpointAddr, SecretKey}; +use tsunagi::agent::Event; +use tsunagi::config::ReconnectPolicy; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::NetworkKeys; +use tsunagi::proto::ControlMessage; +use tsunagi::{Agent, Error}; + +/// An endpoint id nobody is listening for, at an address nothing answers on. +fn dead_candidate() -> EndpointAddr { + let unreachable: SocketAddr = "127.0.0.1:1".parse().unwrap(); + EndpointAddr::new(SecretKey::generate().public()).with_ip_addr(unreachable) +} + +#[tokio::test] +async fn a_dead_candidate_does_not_hold_up_the_reachable_ones() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("dead-candidate"); + let keys = NetworkKeys::derive(&name, &secret); + + // Poison the rendezvous table before anybody real shows up. + let dead = dead_candidate(); + discovery.insert_raw(keys.discovery_key(), dead.clone()); + + let a = TestAgent::spawn(&discovery).await.unwrap(); + let b = TestAgent::spawn(&discovery).await.unwrap(); + let mut events = a.agent.subscribe(); + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + + // The reachable peer still connects. + let peers = wait_for_peers(&a.agent, network_id, 1).await; + assert_eq!(peers, vec![b.agent.endpoint_id()]); + + // And the dead candidate is reported as a failure, not silently forgotten. + wait_event(&mut events, |event| match event { + Event::DialFailed { peer, .. } if *peer == dead.id => Some(()), + _ => None, + }) + .await; + + let status = a.agent.network_status(network_id).await.unwrap(); + assert!( + status + .candidates + .iter() + .any(|candidate| candidate.endpoint_id == dead.id + && candidate.consecutive_failures > 0), + "a failing candidate must stay visible as an unverified candidate" + ); + assert!( + status.peers.iter().all(|peer| peer.endpoint_id != dead.id), + "a candidate must never be reported as a peer" + ); + + a.agent.shutdown().await; + b.agent.shutdown().await; +} + +#[tokio::test] +async fn a_vanished_peer_is_retried_with_backoff_and_others_keep_working() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("vanishing-peer"); + + let watcher = TestAgent::spawn_with( + |cfg| { + cfg.with_reconnect(ReconnectPolicy { + initial_delay: Duration::from_millis(50), + max_delay: Duration::from_millis(200), + factor: 1.5, + jitter: 0.2, + max_consecutive_failures: None, + }) + }, + &discovery, + ) + .await + .unwrap(); + let stayer = TestAgent::spawn(&discovery).await.unwrap(); + let leaver = TestAgent::spawn(&discovery).await.unwrap(); + + let network_id = watcher.agent.join_network(&name, &secret).await.unwrap(); + stayer.agent.join_network(&name, &secret).await.unwrap(); + leaver.agent.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&watcher.agent, network_id, 2).await; + + let leaver_id = leaver.agent.endpoint_id(); + let mut events = watcher.agent.subscribe(); + leaver.agent.shutdown().await; + drop(leaver); + + wait_event(&mut events, |event| match event { + Event::PeerDisconnected { peer, .. } if *peer == leaver_id => Some(()), + _ => None, + }) + .await; + + // The other peer is untouched and still answers. + watcher + .agent + .send( + network_id, + stayer.agent.endpoint_id(), + ControlMessage::Ping { + seq: 3, + payload: b"still here".to_vec(), + }, + ) + .await + .unwrap(); + wait_event(&mut events, |event| match event { + Event::MessageReceived { + peer, + message: ControlMessage::Pong { seq: 3, .. }, + .. + } if *peer == stayer.agent.endpoint_id() => Some(()), + _ => None, + }) + .await; + + // The watcher does retry the peer that went away. + wait_event(&mut events, |event| match event { + Event::DialFailed { peer, .. } if *peer == leaver_id => Some(()), + _ => None, + }) + .await; + + // Retries are bounded by the backoff rather than spinning. + settle().await; + let status = watcher.agent.network_status(network_id).await.unwrap(); + let failures = status + .candidates + .iter() + .find(|candidate| candidate.endpoint_id == leaver_id) + .map(|candidate| candidate.consecutive_failures) + .unwrap_or(0); + assert!( + (1..=40).contains(&failures), + "expected bounded backed-off retries, got {failures}" + ); + assert_eq!( + status.connected_peers(), + vec![stayer.agent.endpoint_id()], + "the surviving peer keeps its session" + ); + + watcher.agent.shutdown().await; + stayer.agent.shutdown().await; +} + +#[tokio::test] +async fn retries_stop_when_the_network_is_deactivated() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("stop-retrying"); + let keys = NetworkKeys::derive(&name, &secret); + discovery.insert_raw(keys.discovery_key(), dead_candidate()); + + let agent = TestAgent::spawn_with( + |cfg| { + cfg.with_discovery_interval(Duration::from_millis(80)) + .with_reconnect(ReconnectPolicy { + initial_delay: Duration::from_millis(20), + max_delay: Duration::from_millis(60), + factor: 1.2, + jitter: 0.1, + max_consecutive_failures: None, + }) + }, + &discovery, + ) + .await + .unwrap(); + + let mut events = agent.agent.subscribe(); + let network_id = agent.agent.join_network(&name, &secret).await.unwrap(); + + // Retries are definitely happening. + wait_event(&mut events, |event| match event { + Event::DialFailed { .. } => Some(()), + _ => None, + }) + .await; + + agent.agent.deactivate_network(network_id).await.unwrap(); + + // Drain whatever was already queued, then require silence. + while events.try_recv().is_ok() {} + settle().await; + let mut stragglers = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, Event::DialFailed { .. }) { + stragglers += 1; + } + } + assert_eq!( + stragglers, 0, + "a deactivated network must stop dialling entirely" + ); + + agent.agent.shutdown().await; +} + +#[tokio::test] +async fn a_second_agent_on_the_same_state_directory_is_refused() { + let discovery = SharedMemoryDiscovery::new(); + let dir = tempfile::TempDir::new().unwrap(); + + let first = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + + let second = Agent::spawn(config_with(dir.path(), &discovery)).await; + match second { + Err(Error::StateLocked { path }) => { + assert!(path.starts_with(dir.path())); + } + Err(other) => panic!("expected StateLocked, got {other:?}"), + Ok(agent) => { + agent.shutdown().await; + panic!("two live agents must not share one state directory"); + } + } + + // After a clean stop the directory is immediately claimable again. + let device_id = first.endpoint_id(); + first.shutdown().await; + drop(first); + + let third = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + assert_eq!(third.endpoint_id(), device_id); + third.shutdown().await; + drop(third); + drop(dir); +} + +#[tokio::test] +async fn shutdown_releases_resources_and_rejects_further_work() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("clean-stop"); + + let a = TestAgent::spawn(&discovery).await.unwrap(); + let b = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&a.agent, network_id, 1).await; + + a.agent.shutdown().await; + + // The endpoint is closed and the networks are gone. + assert!(a.agent.endpoint().is_closed()); + assert!(matches!( + a.agent.network_status(network_id).await, + Err(Error::NetworkNotActive(_)) + )); + assert!( + a.agent + .send( + network_id, + b.agent.endpoint_id(), + ControlMessage::Ping { + seq: 1, + payload: Vec::new() + } + ) + .await + .is_err() + ); + + // Shutting down twice is harmless. + a.agent.shutdown().await; + + // The peer notices and carries on. + let status = b.agent.network_status(network_id).await.unwrap(); + assert_eq!(status.state, tsunagi::agent::NetworkState::Active); + + b.agent.shutdown().await; +} + +#[tokio::test] +async fn several_independent_agents_coexist_in_one_process() { + // No global state: two completely separate rendezvous tables, two networks + // with the same name but different secrets, four agents, one process. + let left = SharedMemoryDiscovery::new(); + let right = SharedMemoryDiscovery::new(); + let (name, left_secret) = network("same-name-different-world"); + let (_, right_secret) = network("ignored"); + + let l1 = TestAgent::spawn(&left).await.unwrap(); + let l2 = TestAgent::spawn(&left).await.unwrap(); + let r1 = TestAgent::spawn(&right).await.unwrap(); + let r2 = TestAgent::spawn(&right).await.unwrap(); + + let left_id = l1.agent.join_network(&name, &left_secret).await.unwrap(); + l2.agent.join_network(&name, &left_secret).await.unwrap(); + let right_id = r1.agent.join_network(&name, &right_secret).await.unwrap(); + r2.agent.join_network(&name, &right_secret).await.unwrap(); + assert_ne!(left_id, right_id); + + wait_for_peers(&l1.agent, left_id, 1).await; + wait_for_peers(&r1.agent, right_id, 1).await; + assert_eq!( + l1.agent + .network_status(left_id) + .await + .unwrap() + .connected_peers(), + vec![l2.agent.endpoint_id()] + ); + + for agent in [l1, l2, r1, r2] { + agent.agent.shutdown().await; + } +} + +#[tokio::test] +async fn an_agent_without_discovery_still_starts_and_serves_status() { + let dir = tempfile::TempDir::new().unwrap(); + let agent = Agent::spawn(local_config(dir.path())).await.unwrap(); + let (name, secret) = network("no-discovery"); + let network_id = agent.join_network(&name, &secret).await.unwrap(); + + let status = agent.network_status(network_id).await.unwrap(); + assert!(status.peers.is_empty()); + assert!(status.candidates.is_empty()); + agent.recheck().await; + assert!(agent.recheck_network(network_id).await.is_ok()); + + agent.shutdown().await; + drop(agent); + drop(dir); +} diff --git a/tests/restart.rs b/tests/restart.rs new file mode 100644 index 0000000..10e196b --- /dev/null +++ b/tests/restart.rs @@ -0,0 +1,183 @@ +//! Scenarios 5 and 6: restart recovery and rotating the network secret. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use common::{TestAgent, config_with, network, settle, wait_event, wait_for_peers}; +use tsunagi::agent::Event; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkName, NetworkSecret}; +use tsunagi::proto::ControlMessage; +use tsunagi::{Agent, Error}; + +#[tokio::test] +async fn a_restarted_agent_keeps_its_identity_and_reconnects() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("survives-restart"); + + let peer = TestAgent::spawn(&discovery).await.unwrap(); + let restarting = TestAgent::spawn(&discovery).await.unwrap(); + + let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); + restarting.agent.join_network(&name, &secret).await.unwrap(); + + wait_for_peers(&peer.agent, network_id, 1).await; + let device_id = restarting.agent.endpoint_id(); + let old_sockets = restarting.agent.status().await.unwrap().bound_sockets; + + let mut peer_events = peer.agent.subscribe(); + let dir = restarting.stop().await; + + // The surviving peer notices the session ending. + wait_event(&mut peer_events, |event| match event { + Event::PeerDisconnected { peer, .. } if *peer == device_id => Some(()), + _ => None, + }) + .await; + + // Restart from the same state directory. Binding to port zero again means a + // different local UDP port, which the refreshed discovery entry covers. + let restarted = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + assert_eq!(restarted.endpoint_id(), device_id); + let new_sockets = restarted.status().await.unwrap().bound_sockets; + assert_ne!(old_sockets, new_sockets, "a fresh local port is expected"); + + // The configured network came back up on its own and both sides reconnect. + assert!(restarted.is_active(network_id).await); + wait_for_peers(&restarted, network_id, 1).await; + wait_for_peers(&peer.agent, network_id, 1).await; + + // And the restored session really works. + restarted + .send( + network_id, + peer.agent.endpoint_id(), + ControlMessage::Ping { + seq: 5, + payload: b"back".to_vec(), + }, + ) + .await + .unwrap(); + let mut events = restarted.subscribe(); + wait_event(&mut events, |event| match event { + Event::MessageReceived { + message: ControlMessage::Pong { seq: 5, payload }, + .. + } if payload == b"back" => Some(()), + _ => None, + }) + .await; + + restarted.shutdown().await; + peer.agent.shutdown().await; + drop(restarted); + drop(dir); +} + +#[tokio::test] +async fn readiness_does_not_wait_for_anyone_else() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("lonely"); + + // No peers exist and no relay is reachable. The agent must still come up. + let alone = TestAgent::spawn(&discovery).await.unwrap(); + let network_id = alone.agent.join_network(&name, &secret).await.unwrap(); + + let status = alone.agent.network_status(network_id).await.unwrap(); + assert!(status.peers.is_empty()); + assert_eq!(status.state, tsunagi::agent::NetworkState::Active); + + alone.agent.shutdown().await; +} + +#[tokio::test] +async fn rotating_the_secret_moves_everyone_to_a_new_space() { + let discovery = SharedMemoryDiscovery::new(); + let name = NetworkName::new("rotate-me").unwrap(); + let old_secret = NetworkSecret::generate(); + let new_secret = NetworkSecret::generate(); + + let a = TestAgent::spawn(&discovery).await.unwrap(); + let b = TestAgent::spawn(&discovery).await.unwrap(); + let a_id = a.agent.endpoint_id(); + + let old = a.agent.join_network(&name, &old_secret).await.unwrap(); + b.agent.join_network(&name, &old_secret).await.unwrap(); + wait_for_peers(&a.agent, old, 1).await; + + // Rotation through the public API: deactivate the old space, join the new. + // No dedicated command is needed for this. + a.agent.deactivate_network(old).await.unwrap(); + let new = a.agent.join_network(&name, &new_secret).await.unwrap(); + assert_ne!(old, new); + assert_eq!(a.agent.endpoint_id(), a_id, "device identity is untouched"); + + // B still holds the old secret, so it must not reach the new space. + settle().await; + let status = a.agent.network_status(new).await.unwrap(); + assert!( + status.peers.is_empty(), + "the old secret must not open the new space" + ); + assert!( + a.agent + .send( + old, + b.agent.endpoint_id(), + ControlMessage::Ping { + seq: 1, + payload: Vec::new() + } + ) + .await + .is_err(), + "the deactivated network cannot be used any more" + ); + + // Once B rotates too, they meet again in the new space. + b.agent.deactivate_network(old).await.unwrap(); + let b_new = b.agent.join_network(&name, &new_secret).await.unwrap(); + assert_eq!(b_new, new); + wait_for_peers(&a.agent, new, 1).await; + + a.agent.shutdown().await; + b.agent.shutdown().await; +} + +#[tokio::test] +async fn a_rotated_out_network_does_not_come_back_after_a_restart() { + let discovery = SharedMemoryDiscovery::new(); + let name = NetworkName::new("no-resurrection").unwrap(); + + let agent = TestAgent::spawn(&discovery).await.unwrap(); + let old = agent + .agent + .join_network(&name, &NetworkSecret::generate()) + .await + .unwrap(); + let new = agent + .agent + .join_network(&name, &NetworkSecret::generate()) + .await + .unwrap(); + agent.agent.deactivate_network(old).await.unwrap(); + let dir = agent.stop().await; + + let restarted = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + assert!(!restarted.is_active(old).await); + assert!(restarted.is_active(new).await); + assert!(matches!( + restarted.network_status(old).await, + Err(Error::NetworkNotActive(_)) + )); + + restarted.shutdown().await; + drop(restarted); + drop(dir); +}