clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

dev.site vertical slice: public links, private local services, sharing

Dami committed 4801ca9
Share public work and securely access local developer services from one
personal page, without deploying those services or opening ports.

Live at https://dev.site, verified with two real Google accounts: @frank
opens @dami's Agent and gets a page served from 127.0.0.1:4102 over an
Iroh relay, while Hermes stays absent from every listing and returns 404
on a direct capability request by id.

Architecture:

  browser (ephemeral Iroh key)
    -> POST /api/capability
  control plane (axum + SQLite)  authorizes, signs an Ed25519 capability
    -> {capability, daemon endpoint, relay url}
  browser -- Iroh, E2EE via relay --> daemon
    verifies signature, audience, expiry, browser key vs the authenticated
    peer, and that the resource is in local config
    -> 127.0.0.1:4101

The browser never names an upstream URL; the daemon resolves the origin
from its own config, keyed by resource id, which is what keeps it from
becoming an open proxy. The control plane holds permissions but never
carries service traffic.

Authentication is Shoo (shoo.dev), an OIDC broker for Google sign-in —
not GitHub, despite the name suggesting otherwise. Browser-side PKCE, an
ES256 id_token verified server-side against JWKS with the algorithm
pinned and the audience checked exactly. Account identity derives from
the public origin, so DEVSITE_PUBLIC_ORIGIN cannot change once anyone has
signed in.

Hosted behind Cloudflare Tunnel, so the control plane has no inbound port
either. The wasm bundle is published under a content hash and cached
immutably; the manifest, HTML and every API response are not.

51 tests. crates/devsite-daemon/tests/authz.rs drives real Iroh against a
real local service and asserts that forged, expired, misaddressed,
rebound, replayed and unknown-resource capabilities are each refused, and
that a denial reveals nothing about which.
commit
4801ca95df7fca90181cca1b120b3b7c1f6c1fc7
author
Dami <dami.etoile@gmail.com>
43 files changed, +11169 -0
A.cargo/config.toml4+
A.gitignore21+
ACargo.lock5005++++++++++++++++++++++++++++++
ACargo.toml27+
AREADME.md129+
Acrates/devsite-cli/Cargo.toml24+
Acrates/devsite-cli/src/client.rs86+
Acrates/devsite-cli/src/main.rs372++
Acrates/devsite-client/Cargo.toml12+
Acrates/devsite-client/src/lib.rs193+
Acrates/devsite-daemon/Cargo.toml25+
Acrates/devsite-daemon/src/config.rs235+
Acrates/devsite-daemon/src/lib.rs214+
Acrates/devsite-daemon/src/upstream.rs162+
Acrates/devsite-daemon/src/verify.rs222+
Acrates/devsite-daemon/tests/authz.rs349++
Acrates/devsite-fixture/Cargo.toml9+
Acrates/devsite-fixture/src/main.rs61+
Acrates/devsite-proto/Cargo.toml13+
Acrates/devsite-proto/src/capability.rs263++
Acrates/devsite-proto/src/id.rs140+
Acrates/devsite-proto/src/lib.rs14+
Acrates/devsite-proto/src/wire.rs158+
Acrates/devsite-server/Cargo.toml28+
Acrates/devsite-server/src/api.rs606++++
Acrates/devsite-server/src/auth.rs221+
Acrates/devsite-server/src/db.rs618++++
Acrates/devsite-server/src/issuer.rs171+
Acrates/devsite-server/src/main.rs199+
Acrates/devsite-server/src/policy.rs94+
Acrates/devsite-web/Cargo.toml21+
Acrates/devsite-web/src/lib.rs87+
Adeploy/README.md57+
Adeploy/env21+
Adeploy/run.sh48+
Adeploy/setup-tunnel.sh171+
Adocs/milestones.md112+
Afixtures/page-a.html38+
Afixtures/page-b.html38+
Ascripts/build-wasm.sh88+
Aweb/app.js406++
Aweb/index.html38+
Aweb/styles.css369++
A .cargo/config.toml +4 -0
A .gitignore +21 -0
A Cargo.lock +5005 -0
@@ -0,0 +1,5005 @@
1+# This file is automatically @generated by Cargo.2+# It is not intended for manual editing.3+version = 44+5+[[package]]6+name = "aead"7+version = "0.5.2"8+source = "registry+https://github.com/rust-lang/crates.io-index"9+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"10+dependencies = [11+ "crypto-common 0.1.7",12+ "generic-array",13+]14+15+[[package]]16+name = "aes"17+version = "0.8.4"18+source = "registry+https://github.com/rust-lang/crates.io-index"19+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"20+dependencies = [21+ "cfg-if",22+ "cipher",23+ "cpufeatures 0.2.17",24+]25+26+[[package]]27+name = "aes-gcm"28+version = "0.10.3"29+source = "registry+https://github.com/rust-lang/crates.io-index"30+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"31+dependencies = [32+ "aead",33+ "aes",34+ "cipher",35+ "ctr",36+ "ghash",37+ "subtle",38+]39+40+[[package]]41+name = "aho-corasick"42+version = "1.1.5"43+source = "registry+https://github.com/rust-lang/crates.io-index"44+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"45+dependencies = [46+ "memchr",47+]48+49+[[package]]50+name = "allocator-api2"51+version = "0.2.21"52+source = "registry+https://github.com/rust-lang/crates.io-index"53+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"54+55+[[package]]56+name = "android_system_properties"57+version = "0.1.6"58+source = "registry+https://github.com/rust-lang/crates.io-index"59+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"60+dependencies = [61+ "libc",62+]63+64+[[package]]65+name = "anstream"66+version = "1.0.0"67+source = "registry+https://github.com/rust-lang/crates.io-index"68+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"69+dependencies = [70+ "anstyle",71+ "anstyle-parse",72+ "anstyle-query",73+ "anstyle-wincon",74+ "colorchoice",75+ "is_terminal_polyfill",76+ "utf8parse",77+]78+79+[[package]]80+name = "anstyle"81+version = "1.0.14"82+source = "registry+https://github.com/rust-lang/crates.io-index"83+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"84+85+[[package]]86+name = "anstyle-parse"87+version = "1.0.0"88+source = "registry+https://github.com/rust-lang/crates.io-index"89+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"90+dependencies = [91+ "utf8parse",92+]93+94+[[package]]95+name = "anstyle-query"96+version = "1.1.5"97+source = "registry+https://github.com/rust-lang/crates.io-index"98+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"99+dependencies = [100+ "windows-sys 0.61.2",101+]102+103+[[package]]104+name = "anstyle-wincon"105+version = "3.0.11"106+source = "registry+https://github.com/rust-lang/crates.io-index"107+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"108+dependencies = [109+ "anstyle",110+ "once_cell_polyfill",111+ "windows-sys 0.61.2",112+]113+114+[[package]]115+name = "anyhow"116+version = "1.0.104"117+source = "registry+https://github.com/rust-lang/crates.io-index"118+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"119+120+[[package]]121+name = "anymap2"122+version = "0.13.0"123+source = "registry+https://github.com/rust-lang/crates.io-index"124+checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"125+126+[[package]]127+name = "arc-swap"128+version = "1.9.2"129+source = "registry+https://github.com/rust-lang/crates.io-index"130+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"131+dependencies = [132+ "rustversion",133+]134+135+[[package]]136+name = "arrayref"137+version = "0.3.9"138+source = "registry+https://github.com/rust-lang/crates.io-index"139+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"140+141+[[package]]142+name = "arrayvec"143+version = "0.7.8"144+source = "registry+https://github.com/rust-lang/crates.io-index"145+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"146+147+[[package]]148+name = "async-trait"149+version = "0.1.92"150+source = "registry+https://github.com/rust-lang/crates.io-index"151+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"152+dependencies = [153+ "proc-macro2",154+ "quote",155+ "syn 3.0.3",156+]157+158+[[package]]159+name = "async_io_stream"160+version = "0.3.3"161+source = "registry+https://github.com/rust-lang/crates.io-index"162+checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"163+dependencies = [164+ "futures",165+ "pharos",166+ "rustc_version",167+]168+169+[[package]]170+name = "atomic-waker"171+version = "1.1.2"172+source = "registry+https://github.com/rust-lang/crates.io-index"173+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"174+175+[[package]]176+name = "attohttpc"177+version = "0.30.1"178+source = "registry+https://github.com/rust-lang/crates.io-index"179+checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"180+dependencies = [181+ "base64",182+ "http 1.5.0",183+ "log",184+ "url",185+]186+187+[[package]]188+name = "autocfg"189+version = "1.5.1"190+source = "registry+https://github.com/rust-lang/crates.io-index"191+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"192+193+[[package]]194+name = "axum"195+version = "0.8.9"196+source = "registry+https://github.com/rust-lang/crates.io-index"197+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"198+dependencies = [199+ "axum-core",200+ "axum-macros",201+ "bytes",202+ "form_urlencoded",203+ "futures-util",204+ "http 1.5.0",205+ "http-body",206+ "http-body-util",207+ "hyper",208+ "hyper-util",209+ "itoa",210+ "matchit",211+ "memchr",212+ "mime",213+ "percent-encoding",214+ "pin-project-lite",215+ "serde_core",216+ "serde_json",217+ "serde_path_to_error",218+ "serde_urlencoded",219+ "sync_wrapper",220+ "tokio",221+ "tower",222+ "tower-layer",223+ "tower-service",224+ "tracing",225+]226+227+[[package]]228+name = "axum-core"229+version = "0.5.6"230+source = "registry+https://github.com/rust-lang/crates.io-index"231+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"232+dependencies = [233+ "bytes",234+ "futures-core",235+ "http 1.5.0",236+ "http-body",237+ "http-body-util",238+ "mime",239+ "pin-project-lite",240+ "sync_wrapper",241+ "tower-layer",242+ "tower-service",243+ "tracing",244+]245+246+[[package]]247+name = "axum-macros"248+version = "0.5.1"249+source = "registry+https://github.com/rust-lang/crates.io-index"250+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"251+dependencies = [252+ "proc-macro2",253+ "quote",254+ "syn 2.0.119",255+]256+257+[[package]]258+name = "backon"259+version = "1.6.0"260+source = "registry+https://github.com/rust-lang/crates.io-index"261+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"262+dependencies = [263+ "fastrand",264+ "gloo-timers 0.3.0",265+ "tokio",266+]267+268+[[package]]269+name = "base16ct"270+version = "1.0.0"271+source = "registry+https://github.com/rust-lang/crates.io-index"272+checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"273+274+[[package]]275+name = "base64"276+version = "0.22.1"277+source = "registry+https://github.com/rust-lang/crates.io-index"278+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"279+280+[[package]]281+name = "base64ct"282+version = "1.8.3"283+source = "registry+https://github.com/rust-lang/crates.io-index"284+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"285+286+[[package]]287+name = "bincode"288+version = "1.3.3"289+source = "registry+https://github.com/rust-lang/crates.io-index"290+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"291+dependencies = [292+ "serde",293+]294+295+[[package]]296+name = "bitflags"297+version = "2.13.1"298+source = "registry+https://github.com/rust-lang/crates.io-index"299+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"300+301+[[package]]302+name = "blake3"303+version = "1.8.6"304+source = "registry+https://github.com/rust-lang/crates.io-index"305+checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77"306+dependencies = [307+ "arrayref",308+ "arrayvec",309+ "cc",310+ "cfg-if",311+ "constant_time_eq",312+ "cpufeatures 0.3.0",313+]314+315+[[package]]316+name = "block-buffer"317+version = "0.10.4"318+source = "registry+https://github.com/rust-lang/crates.io-index"319+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"320+dependencies = [321+ "generic-array",322+]323+324+[[package]]325+name = "block-buffer"326+version = "0.12.1"327+source = "registry+https://github.com/rust-lang/crates.io-index"328+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"329+dependencies = [330+ "hybrid-array",331+]332+333+[[package]]334+name = "block2"335+version = "0.6.2"336+source = "registry+https://github.com/rust-lang/crates.io-index"337+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"338+dependencies = [339+ "objc2",340+]341+342+[[package]]343+name = "bumpalo"344+version = "3.20.3"345+source = "registry+https://github.com/rust-lang/crates.io-index"346+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"347+348+[[package]]349+name = "bytes"350+version = "1.12.1"351+source = "registry+https://github.com/rust-lang/crates.io-index"352+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"353+354+[[package]]355+name = "cc"356+version = "1.4.2"357+source = "registry+https://github.com/rust-lang/crates.io-index"358+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"359+dependencies = [360+ "find-msvc-tools",361+ "shlex",362+]363+364+[[package]]365+name = "cesu8"366+version = "1.1.0"367+source = "registry+https://github.com/rust-lang/crates.io-index"368+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"369+370+[[package]]371+name = "cfg-if"372+version = "1.0.4"373+source = "registry+https://github.com/rust-lang/crates.io-index"374+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"375+376+[[package]]377+name = "cfg_aliases"378+version = "0.2.2"379+source = "registry+https://github.com/rust-lang/crates.io-index"380+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"381+382+[[package]]383+name = "chacha20"384+version = "0.10.1"385+source = "registry+https://github.com/rust-lang/crates.io-index"386+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"387+dependencies = [388+ "cfg-if",389+ "cpufeatures 0.3.0",390+ "rand_core 0.10.1",391+]392+393+[[package]]394+name = "chrono"395+version = "0.4.45"396+source = "registry+https://github.com/rust-lang/crates.io-index"397+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"398+dependencies = [399+ "iana-time-zone",400+ "num-traits",401+ "serde",402+ "windows-link",403+]404+405+[[package]]406+name = "cipher"407+version = "0.4.4"408+source = "registry+https://github.com/rust-lang/crates.io-index"409+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"410+dependencies = [411+ "crypto-common 0.1.7",412+ "inout",413+]414+415+[[package]]416+name = "clap"417+version = "4.6.6"418+source = "registry+https://github.com/rust-lang/crates.io-index"419+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"420+dependencies = [421+ "clap_builder",422+ "clap_derive",423+]424+425+[[package]]426+name = "clap_builder"427+version = "4.6.6"428+source = "registry+https://github.com/rust-lang/crates.io-index"429+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"430+dependencies = [431+ "anstream",432+ "anstyle",433+ "clap_lex",434+ "strsim",435+]436+437+[[package]]438+name = "clap_derive"439+version = "4.6.4"440+source = "registry+https://github.com/rust-lang/crates.io-index"441+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"442+dependencies = [443+ "heck",444+ "proc-macro2",445+ "quote",446+ "syn 3.0.3",447+]448+449+[[package]]450+name = "clap_lex"451+version = "1.1.0"452+source = "registry+https://github.com/rust-lang/crates.io-index"453+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"454+455+[[package]]456+name = "cmov"457+version = "0.5.4"458+source = "registry+https://github.com/rust-lang/crates.io-index"459+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"460+461+[[package]]462+name = "cobs"463+version = "0.3.0"464+source = "registry+https://github.com/rust-lang/crates.io-index"465+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"466+dependencies = [467+ "thiserror 2.0.20",468+]469+470+[[package]]471+name = "colorchoice"472+version = "1.0.5"473+source = "registry+https://github.com/rust-lang/crates.io-index"474+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"475+476+[[package]]477+name = "combine"478+version = "4.6.7"479+source = "registry+https://github.com/rust-lang/crates.io-index"480+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"481+dependencies = [482+ "bytes",483+ "memchr",484+]485+486+[[package]]487+name = "console_error_panic_hook"488+version = "0.1.7"489+source = "registry+https://github.com/rust-lang/crates.io-index"490+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"491+dependencies = [492+ "cfg-if",493+ "wasm-bindgen",494+]495+496+[[package]]497+name = "const-oid"498+version = "0.9.6"499+source = "registry+https://github.com/rust-lang/crates.io-index"500+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"501+502+[[package]]503+name = "const-oid"504+version = "0.10.2"505+source = "registry+https://github.com/rust-lang/crates.io-index"506+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"507+508+[[package]]509+name = "constant_time_eq"510+version = "0.4.2"511+source = "registry+https://github.com/rust-lang/crates.io-index"512+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"513+514+[[package]]515+name = "convert_case"516+version = "0.10.0"517+source = "registry+https://github.com/rust-lang/crates.io-index"518+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"519+dependencies = [520+ "unicode-segmentation",521+]522+523+[[package]]524+name = "cordyceps"525+version = "0.3.5"526+source = "registry+https://github.com/rust-lang/crates.io-index"527+checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261"528+dependencies = [529+ "loom",530+ "tracing",531+]532+533+[[package]]534+name = "core-foundation"535+version = "0.9.4"536+source = "registry+https://github.com/rust-lang/crates.io-index"537+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"538+dependencies = [539+ "core-foundation-sys",540+ "libc",541+]542+543+[[package]]544+name = "core-foundation"545+version = "0.10.1"546+source = "registry+https://github.com/rust-lang/crates.io-index"547+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"548+dependencies = [549+ "core-foundation-sys",550+ "libc",551+]552+553+[[package]]554+name = "core-foundation-sys"555+version = "0.8.7"556+source = "registry+https://github.com/rust-lang/crates.io-index"557+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"558+559+[[package]]560+name = "cpufeatures"561+version = "0.2.17"562+source = "registry+https://github.com/rust-lang/crates.io-index"563+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"564+dependencies = [565+ "libc",566+]567+568+[[package]]569+name = "cpufeatures"570+version = "0.3.0"571+source = "registry+https://github.com/rust-lang/crates.io-index"572+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"573+dependencies = [574+ "libc",575+]576+577+[[package]]578+name = "critical-section"579+version = "1.2.0"580+source = "registry+https://github.com/rust-lang/crates.io-index"581+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"582+583+[[package]]584+name = "crossbeam-channel"585+version = "0.5.16"586+source = "registry+https://github.com/rust-lang/crates.io-index"587+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"588+dependencies = [589+ "crossbeam-utils",590+]591+592+[[package]]593+name = "crossbeam-epoch"594+version = "0.9.20"595+source = "registry+https://github.com/rust-lang/crates.io-index"596+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"597+dependencies = [598+ "crossbeam-utils",599+]600+601+[[package]]602+name = "crossbeam-utils"603+version = "0.8.22"604+source = "registry+https://github.com/rust-lang/crates.io-index"605+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"606+607+[[package]]608+name = "crypto-common"609+version = "0.1.7"610+source = "registry+https://github.com/rust-lang/crates.io-index"611+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"612+dependencies = [613+ "generic-array",614+ "typenum",615+]616+617+[[package]]618+name = "crypto-common"619+version = "0.2.2"620+source = "registry+https://github.com/rust-lang/crates.io-index"621+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"622+dependencies = [623+ "hybrid-array",624+ "rand_core 0.10.1",625+]626+627+[[package]]628+name = "ctr"629+version = "0.9.2"630+source = "registry+https://github.com/rust-lang/crates.io-index"631+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"632+dependencies = [633+ "cipher",634+]635+636+[[package]]637+name = "ctutils"638+version = "0.4.2"639+source = "registry+https://github.com/rust-lang/crates.io-index"640+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"641+dependencies = [642+ "cmov",643+]644+645+[[package]]646+name = "curve25519-dalek"647+version = "4.1.3"648+source = "registry+https://github.com/rust-lang/crates.io-index"649+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"650+dependencies = [651+ "cfg-if",652+ "cpufeatures 0.2.17",653+ "curve25519-dalek-derive",654+ "digest 0.10.7",655+ "fiat-crypto 0.2.9",656+ "rustc_version",657+ "subtle",658+ "zeroize",659+]660+661+[[package]]662+name = "curve25519-dalek"663+version = "5.0.0"664+source = "registry+https://github.com/rust-lang/crates.io-index"665+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"666+dependencies = [667+ "cfg-if",668+ "cpufeatures 0.3.0",669+ "curve25519-dalek-derive",670+ "digest 0.11.3",671+ "fiat-crypto 0.3.0",672+ "rand_core 0.10.1",673+ "rustc_version",674+ "serde",675+ "subtle",676+ "zeroize",677+]678+679+[[package]]680+name = "curve25519-dalek-derive"681+version = "0.1.1"682+source = "registry+https://github.com/rust-lang/crates.io-index"683+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"684+dependencies = [685+ "proc-macro2",686+ "quote",687+ "syn 2.0.119",688+]689+690+[[package]]691+name = "data-encoding"692+version = "2.11.1"693+source = "registry+https://github.com/rust-lang/crates.io-index"694+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"695+696+[[package]]697+name = "data-encoding-macro"698+version = "0.1.21"699+source = "registry+https://github.com/rust-lang/crates.io-index"700+checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8"701+dependencies = [702+ "data-encoding",703+ "data-encoding-macro-internal",704+]705+706+[[package]]707+name = "data-encoding-macro-internal"708+version = "0.1.19"709+source = "registry+https://github.com/rust-lang/crates.io-index"710+checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead"711+dependencies = [712+ "data-encoding",713+ "syn 3.0.3",714+]715+716+[[package]]717+name = "der"718+version = "0.7.10"719+source = "registry+https://github.com/rust-lang/crates.io-index"720+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"721+dependencies = [722+ "const-oid 0.9.6",723+ "zeroize",724+]725+726+[[package]]727+name = "der"728+version = "0.8.1"729+source = "registry+https://github.com/rust-lang/crates.io-index"730+checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"731+dependencies = [732+ "const-oid 0.10.2",733+ "pem-rfc7468",734+ "zeroize",735+]736+737+[[package]]738+name = "deranged"739+version = "0.5.8"740+source = "registry+https://github.com/rust-lang/crates.io-index"741+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"742+743+[[package]]744+name = "derive_more"745+version = "2.1.1"746+source = "registry+https://github.com/rust-lang/crates.io-index"747+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"748+dependencies = [749+ "derive_more-impl",750+]751+752+[[package]]753+name = "derive_more-impl"754+version = "2.1.1"755+source = "registry+https://github.com/rust-lang/crates.io-index"756+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"757+dependencies = [758+ "convert_case",759+ "proc-macro2",760+ "quote",761+ "rustc_version",762+ "syn 2.0.119",763+ "unicode-xid",764+]765+766+[[package]]767+name = "devsite-cli"768+version = "0.1.0"769+dependencies = [770+ "anyhow",771+ "clap",772+ "data-encoding",773+ "devsite-daemon",774+ "devsite-proto",775+ "iroh",776+ "reqwest 0.12.28",777+ "serde",778+ "serde_json",779+ "tokio",780+ "tracing",781+ "tracing-subscriber",782+ "url",783+]784+785+[[package]]786+name = "devsite-client"787+version = "0.1.0"788+dependencies = [789+ "anyhow",790+ "devsite-proto",791+ "iroh",792+ "thiserror 2.0.20",793+ "tracing",794+]795+796+[[package]]797+name = "devsite-daemon"798+version = "0.1.0"799+dependencies = [800+ "anyhow",801+ "data-encoding",802+ "devsite-client",803+ "devsite-proto",804+ "directories",805+ "ed25519-dalek 2.2.0",806+ "getrandom 0.3.4",807+ "iroh",808+ "reqwest 0.12.28",809+ "serde",810+ "serde_json",811+ "tokio",812+ "tracing",813+ "tracing-subscriber",814+ "url",815+]816+817+[[package]]818+name = "devsite-fixture"819+version = "0.1.0"820+dependencies = [821+ "anyhow",822+ "tokio",823+]824+825+[[package]]826+name = "devsite-proto"827+version = "0.1.0"828+dependencies = [829+ "data-encoding",830+ "ed25519-dalek 2.2.0",831+ "getrandom 0.3.4",832+ "postcard",833+ "serde",834+ "thiserror 2.0.20",835+]836+837+[[package]]838+name = "devsite-server"839+version = "0.1.0"840+dependencies = [841+ "anyhow",842+ "axum",843+ "data-encoding",844+ "devsite-proto",845+ "ed25519-dalek 2.2.0",846+ "jsonwebtoken",847+ "rand 0.8.7",848+ "reqwest 0.12.28",849+ "rusqlite",850+ "serde",851+ "serde_json",852+ "sha2 0.10.9",853+ "tokio",854+ "tower-http",855+ "tracing",856+ "tracing-subscriber",857+ "url",858+]859+860+[[package]]861+name = "devsite-web"862+version = "0.1.0"863+dependencies = [864+ "console_error_panic_hook",865+ "data-encoding",866+ "devsite-client",867+ "devsite-proto",868+ "getrandom 0.3.4",869+ "iroh",870+ "tracing",871+ "tracing-subscriber",872+ "tracing-subscriber-wasm",873+ "wasm-bindgen",874+ "wasm-bindgen-futures",875+]876+877+[[package]]878+name = "diatomic-waker"879+version = "0.2.3"880+source = "registry+https://github.com/rust-lang/crates.io-index"881+checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c"882+883+[[package]]884+name = "digest"885+version = "0.10.7"886+source = "registry+https://github.com/rust-lang/crates.io-index"887+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"888+dependencies = [889+ "block-buffer 0.10.4",890+ "crypto-common 0.1.7",891+]892+893+[[package]]894+name = "digest"895+version = "0.11.3"896+source = "registry+https://github.com/rust-lang/crates.io-index"897+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"898+dependencies = [899+ "block-buffer 0.12.1",900+ "crypto-common 0.2.2",901+]902+903+[[package]]904+name = "directories"905+version = "6.0.0"906+source = "registry+https://github.com/rust-lang/crates.io-index"907+checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"908+dependencies = [909+ "dirs-sys",910+]911+912+[[package]]913+name = "dirs-sys"914+version = "0.5.0"915+source = "registry+https://github.com/rust-lang/crates.io-index"916+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"917+dependencies = [918+ "libc",919+ "option-ext",920+ "redox_users",921+ "windows-sys 0.61.2",922+]923+924+[[package]]925+name = "dispatch2"926+version = "0.3.1"927+source = "registry+https://github.com/rust-lang/crates.io-index"928+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"929+dependencies = [930+ "bitflags",931+ "block2",932+ "libc",933+ "objc2",934+]935+936+[[package]]937+name = "displaydoc"938+version = "0.2.7"939+source = "registry+https://github.com/rust-lang/crates.io-index"940+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"941+dependencies = [942+ "proc-macro2",943+ "quote",944+ "syn 3.0.3",945+]946+947+[[package]]948+name = "dlopen2"949+version = "0.8.2"950+source = "registry+https://github.com/rust-lang/crates.io-index"951+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"952+dependencies = [953+ "libc",954+ "once_cell",955+ "winapi",956+]957+958+[[package]]959+name = "ed25519"960+version = "2.2.3"961+source = "registry+https://github.com/rust-lang/crates.io-index"962+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"963+dependencies = [964+ "pkcs8 0.10.2",965+ "signature 2.2.0",966+]967+968+[[package]]969+name = "ed25519"970+version = "3.0.0"971+source = "registry+https://github.com/rust-lang/crates.io-index"972+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"973+dependencies = [974+ "pkcs8 0.11.0",975+ "serdect",976+ "signature 3.0.0",977+]978+979+[[package]]980+name = "ed25519-dalek"981+version = "2.2.0"982+source = "registry+https://github.com/rust-lang/crates.io-index"983+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"984+dependencies = [985+ "curve25519-dalek 4.1.3",986+ "ed25519 2.2.3",987+ "rand_core 0.6.4",988+ "serde",989+ "sha2 0.10.9",990+ "subtle",991+ "zeroize",992+]993+994+[[package]]995+name = "ed25519-dalek"996+version = "3.0.0"997+source = "registry+https://github.com/rust-lang/crates.io-index"998+checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"999+dependencies = [1000+ "curve25519-dalek 5.0.0",1001+ "ed25519 3.0.0",1002+ "rand_core 0.10.1",1003+ "serde",1004+ "sha2 0.11.0",1005+ "signature 3.0.0",1006+ "subtle",1007+ "zeroize",1008+]1009+1010+[[package]]1011+name = "either"1012+version = "1.17.0"1013+source = "registry+https://github.com/rust-lang/crates.io-index"1014+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"1015+1016+[[package]]1017+name = "embedded-io"1018+version = "0.4.0"1019+source = "registry+https://github.com/rust-lang/crates.io-index"1020+checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"1021+1022+[[package]]1023+name = "embedded-io"1024+version = "0.6.1"1025+source = "registry+https://github.com/rust-lang/crates.io-index"1026+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"1027+1028+[[package]]1029+name = "enum-assoc"1030+version = "1.3.0"1031+source = "registry+https://github.com/rust-lang/crates.io-index"1032+checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7"1033+dependencies = [1034+ "proc-macro2",1035+ "quote",1036+ "syn 2.0.119",1037+]1038+1039+[[package]]1040+name = "equivalent"1041+version = "1.0.2"1042+source = "registry+https://github.com/rust-lang/crates.io-index"1043+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"1044+1045+[[package]]1046+name = "errno"1047+version = "0.3.14"1048+source = "registry+https://github.com/rust-lang/crates.io-index"1049+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"1050+dependencies = [1051+ "libc",1052+ "windows-sys 0.61.2",1053+]1054+1055+[[package]]1056+name = "fallible-iterator"1057+version = "0.3.0"1058+source = "registry+https://github.com/rust-lang/crates.io-index"1059+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"1060+1061+[[package]]1062+name = "fallible-streaming-iterator"1063+version = "0.1.9"1064+source = "registry+https://github.com/rust-lang/crates.io-index"1065+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"1066+1067+[[package]]1068+name = "fastrand"1069+version = "2.5.0"1070+source = "registry+https://github.com/rust-lang/crates.io-index"1071+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"1072+1073+[[package]]1074+name = "fiat-crypto"1075+version = "0.2.9"1076+source = "registry+https://github.com/rust-lang/crates.io-index"1077+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"1078+1079+[[package]]1080+name = "fiat-crypto"1081+version = "0.3.0"1082+source = "registry+https://github.com/rust-lang/crates.io-index"1083+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"1084+1085+[[package]]1086+name = "find-msvc-tools"1087+version = "0.1.10"1088+source = "registry+https://github.com/rust-lang/crates.io-index"1089+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"1090+1091+[[package]]1092+name = "fnv"1093+version = "1.0.7"1094+source = "registry+https://github.com/rust-lang/crates.io-index"1095+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"1096+1097+[[package]]1098+name = "foldhash"1099+version = "0.1.5"1100+source = "registry+https://github.com/rust-lang/crates.io-index"1101+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"1102+1103+[[package]]1104+name = "foldhash"1105+version = "0.2.0"1106+source = "registry+https://github.com/rust-lang/crates.io-index"1107+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"1108+1109+[[package]]1110+name = "form_urlencoded"1111+version = "1.2.2"1112+source = "registry+https://github.com/rust-lang/crates.io-index"1113+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"1114+dependencies = [1115+ "percent-encoding",1116+]1117+1118+[[package]]1119+name = "futures"1120+version = "0.3.33"1121+source = "registry+https://github.com/rust-lang/crates.io-index"1122+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"1123+dependencies = [1124+ "futures-channel",1125+ "futures-core",1126+ "futures-executor",1127+ "futures-io",1128+ "futures-sink",1129+ "futures-task",1130+ "futures-util",1131+]1132+1133+[[package]]1134+name = "futures-buffered"1135+version = "0.2.13"1136+source = "registry+https://github.com/rust-lang/crates.io-index"1137+checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5"1138+dependencies = [1139+ "cordyceps",1140+ "diatomic-waker",1141+ "futures-core",1142+ "pin-project-lite",1143+ "spin",1144+]1145+1146+[[package]]1147+name = "futures-channel"1148+version = "0.3.33"1149+source = "registry+https://github.com/rust-lang/crates.io-index"1150+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"1151+dependencies = [1152+ "futures-core",1153+ "futures-sink",1154+]1155+1156+[[package]]1157+name = "futures-core"1158+version = "0.3.33"1159+source = "registry+https://github.com/rust-lang/crates.io-index"1160+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"1161+1162+[[package]]1163+name = "futures-executor"1164+version = "0.3.33"1165+source = "registry+https://github.com/rust-lang/crates.io-index"1166+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"1167+dependencies = [1168+ "futures-core",1169+ "futures-task",1170+ "futures-util",1171+]1172+1173+[[package]]1174+name = "futures-io"1175+version = "0.3.33"1176+source = "registry+https://github.com/rust-lang/crates.io-index"1177+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"1178+1179+[[package]]1180+name = "futures-lite"1181+version = "2.6.1"1182+source = "registry+https://github.com/rust-lang/crates.io-index"1183+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"1184+dependencies = [1185+ "fastrand",1186+ "futures-core",1187+ "futures-io",1188+ "parking",1189+ "pin-project-lite",1190+]1191+1192+[[package]]1193+name = "futures-macro"1194+version = "0.3.33"1195+source = "registry+https://github.com/rust-lang/crates.io-index"1196+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"1197+dependencies = [1198+ "proc-macro2",1199+ "quote",1200+ "syn 2.0.119",1201+]1202+1203+[[package]]1204+name = "futures-sink"1205+version = "0.3.33"1206+source = "registry+https://github.com/rust-lang/crates.io-index"1207+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"1208+1209+[[package]]1210+name = "futures-task"1211+version = "0.3.33"1212+source = "registry+https://github.com/rust-lang/crates.io-index"1213+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"1214+1215+[[package]]1216+name = "futures-util"1217+version = "0.3.33"1218+source = "registry+https://github.com/rust-lang/crates.io-index"1219+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"1220+dependencies = [1221+ "futures-channel",1222+ "futures-core",1223+ "futures-io",1224+ "futures-macro",1225+ "futures-sink",1226+ "futures-task",1227+ "memchr",1228+ "pin-project-lite",1229+ "slab",1230+]1231+1232+[[package]]1233+name = "generator"1234+version = "0.8.9"1235+source = "registry+https://github.com/rust-lang/crates.io-index"1236+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"1237+dependencies = [1238+ "cc",1239+ "cfg-if",1240+ "libc",1241+ "log",1242+ "rustversion",1243+ "windows-link",1244+ "windows-result",1245+]1246+1247+[[package]]1248+name = "generic-array"1249+version = "0.14.7"1250+source = "registry+https://github.com/rust-lang/crates.io-index"1251+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"1252+dependencies = [1253+ "typenum",1254+ "version_check",1255+]1256+1257+[[package]]1258+name = "getrandom"1259+version = "0.2.17"1260+source = "registry+https://github.com/rust-lang/crates.io-index"1261+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"1262+dependencies = [1263+ "cfg-if",1264+ "js-sys",1265+ "libc",1266+ "wasi",1267+ "wasm-bindgen",1268+]1269+1270+[[package]]1271+name = "getrandom"1272+version = "0.3.4"1273+source = "registry+https://github.com/rust-lang/crates.io-index"1274+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"1275+dependencies = [1276+ "cfg-if",1277+ "js-sys",1278+ "libc",1279+ "r-efi 5.3.0",1280+ "wasip2",1281+ "wasm-bindgen",1282+]1283+1284+[[package]]1285+name = "getrandom"1286+version = "0.4.3"1287+source = "registry+https://github.com/rust-lang/crates.io-index"1288+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"1289+dependencies = [1290+ "cfg-if",1291+ "js-sys",1292+ "libc",1293+ "r-efi 6.0.0",1294+ "rand_core 0.10.1",1295+ "wasm-bindgen",1296+]1297+1298+[[package]]1299+name = "ghash"1300+version = "0.5.1"1301+source = "registry+https://github.com/rust-lang/crates.io-index"1302+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"1303+dependencies = [1304+ "opaque-debug",1305+ "polyval",1306+]1307+1308+[[package]]1309+name = "gloo"1310+version = "0.8.1"1311+source = "registry+https://github.com/rust-lang/crates.io-index"1312+checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d"1313+dependencies = [1314+ "gloo-console",1315+ "gloo-dialogs",1316+ "gloo-events",1317+ "gloo-file",1318+ "gloo-history",1319+ "gloo-net",1320+ "gloo-render",1321+ "gloo-storage",1322+ "gloo-timers 0.2.6",1323+ "gloo-utils",1324+ "gloo-worker",1325+]1326+1327+[[package]]1328+name = "gloo-console"1329+version = "0.2.3"1330+source = "registry+https://github.com/rust-lang/crates.io-index"1331+checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f"1332+dependencies = [1333+ "gloo-utils",1334+ "js-sys",1335+ "serde",1336+ "wasm-bindgen",1337+ "web-sys",1338+]1339+1340+[[package]]1341+name = "gloo-dialogs"1342+version = "0.1.1"1343+source = "registry+https://github.com/rust-lang/crates.io-index"1344+checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6"1345+dependencies = [1346+ "wasm-bindgen",1347+ "web-sys",1348+]1349+1350+[[package]]1351+name = "gloo-events"1352+version = "0.1.2"1353+source = "registry+https://github.com/rust-lang/crates.io-index"1354+checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc"1355+dependencies = [1356+ "wasm-bindgen",1357+ "web-sys",1358+]1359+1360+[[package]]1361+name = "gloo-file"1362+version = "0.2.3"1363+source = "registry+https://github.com/rust-lang/crates.io-index"1364+checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7"1365+dependencies = [1366+ "gloo-events",1367+ "js-sys",1368+ "wasm-bindgen",1369+ "web-sys",1370+]1371+1372+[[package]]1373+name = "gloo-history"1374+version = "0.1.5"1375+source = "registry+https://github.com/rust-lang/crates.io-index"1376+checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f"1377+dependencies = [1378+ "gloo-events",1379+ "gloo-utils",1380+ "serde",1381+ "serde-wasm-bindgen",1382+ "serde_urlencoded",1383+ "thiserror 1.0.69",1384+ "wasm-bindgen",1385+ "web-sys",1386+]1387+1388+[[package]]1389+name = "gloo-net"1390+version = "0.3.1"1391+source = "registry+https://github.com/rust-lang/crates.io-index"1392+checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620"1393+dependencies = [1394+ "futures-channel",1395+ "futures-core",1396+ "futures-sink",1397+ "gloo-utils",1398+ "http 0.2.12",1399+ "js-sys",1400+ "pin-project",1401+ "serde",1402+ "serde_json",1403+ "thiserror 1.0.69",1404+ "wasm-bindgen",1405+ "wasm-bindgen-futures",1406+ "web-sys",1407+]1408+1409+[[package]]1410+name = "gloo-render"1411+version = "0.1.1"1412+source = "registry+https://github.com/rust-lang/crates.io-index"1413+checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764"1414+dependencies = [1415+ "wasm-bindgen",1416+ "web-sys",1417+]1418+1419+[[package]]1420+name = "gloo-storage"1421+version = "0.2.2"1422+source = "registry+https://github.com/rust-lang/crates.io-index"1423+checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480"1424+dependencies = [1425+ "gloo-utils",1426+ "js-sys",1427+ "serde",1428+ "serde_json",1429+ "thiserror 1.0.69",1430+ "wasm-bindgen",1431+ "web-sys",1432+]1433+1434+[[package]]1435+name = "gloo-timers"1436+version = "0.2.6"1437+source = "registry+https://github.com/rust-lang/crates.io-index"1438+checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"1439+dependencies = [1440+ "js-sys",1441+ "wasm-bindgen",1442+]1443+1444+[[package]]1445+name = "gloo-timers"1446+version = "0.3.0"1447+source = "registry+https://github.com/rust-lang/crates.io-index"1448+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"1449+dependencies = [1450+ "futures-channel",1451+ "futures-core",1452+ "js-sys",1453+ "wasm-bindgen",1454+]1455+1456+[[package]]1457+name = "gloo-utils"1458+version = "0.1.7"1459+source = "registry+https://github.com/rust-lang/crates.io-index"1460+checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"1461+dependencies = [1462+ "js-sys",1463+ "serde",1464+ "serde_json",1465+ "wasm-bindgen",1466+ "web-sys",1467+]1468+1469+[[package]]1470+name = "gloo-worker"1471+version = "0.2.1"1472+source = "registry+https://github.com/rust-lang/crates.io-index"1473+checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a"1474+dependencies = [1475+ "anymap2",1476+ "bincode",1477+ "gloo-console",1478+ "gloo-utils",1479+ "js-sys",1480+ "serde",1481+ "wasm-bindgen",1482+ "wasm-bindgen-futures",1483+ "web-sys",1484+]1485+1486+[[package]]1487+name = "h2"1488+version = "0.4.15"1489+source = "registry+https://github.com/rust-lang/crates.io-index"1490+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"1491+dependencies = [1492+ "atomic-waker",1493+ "bytes",1494+ "fnv",1495+ "futures-core",1496+ "futures-sink",1497+ "http 1.5.0",1498+ "indexmap",1499+ "slab",1500+ "tokio",1501+ "tokio-util",1502+ "tracing",1503+]1504+1505+[[package]]1506+name = "hashbrown"1507+version = "0.15.5"1508+source = "registry+https://github.com/rust-lang/crates.io-index"1509+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"1510+dependencies = [1511+ "foldhash 0.1.5",1512+]1513+1514+[[package]]1515+name = "hashbrown"1516+version = "0.17.1"1517+source = "registry+https://github.com/rust-lang/crates.io-index"1518+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"1519+dependencies = [1520+ "allocator-api2",1521+ "equivalent",1522+ "foldhash 0.2.0",1523+]1524+1525+[[package]]1526+name = "hashlink"1527+version = "0.10.0"1528+source = "registry+https://github.com/rust-lang/crates.io-index"1529+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"1530+dependencies = [1531+ "hashbrown 0.15.5",1532+]1533+1534+[[package]]1535+name = "heck"1536+version = "0.5.0"1537+source = "registry+https://github.com/rust-lang/crates.io-index"1538+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"1539+1540+[[package]]1541+name = "hickory-net"1542+version = "0.26.1"1543+source = "registry+https://github.com/rust-lang/crates.io-index"1544+checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"1545+dependencies = [1546+ "async-trait",1547+ "bytes",1548+ "cfg-if",1549+ "data-encoding",1550+ "futures-channel",1551+ "futures-io",1552+ "futures-util",1553+ "h2",1554+ "hickory-proto",1555+ "http 1.5.0",1556+ "idna",1557+ "ipnet",1558+ "jni 0.22.4",1559+ "rand 0.10.2",1560+ "rustls",1561+ "thiserror 2.0.20",1562+ "tinyvec",1563+ "tokio",1564+ "tokio-rustls",1565+ "tracing",1566+ "url",1567+]1568+1569+[[package]]1570+name = "hickory-proto"1571+version = "0.26.1"1572+source = "registry+https://github.com/rust-lang/crates.io-index"1573+checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"1574+dependencies = [1575+ "data-encoding",1576+ "idna",1577+ "ipnet",1578+ "jni 0.22.4",1579+ "once_cell",1580+ "prefix-trie",1581+ "rand 0.10.2",1582+ "ring",1583+ "thiserror 2.0.20",1584+ "tinyvec",1585+ "tracing",1586+ "url",1587+]1588+1589+[[package]]1590+name = "hickory-resolver"1591+version = "0.26.1"1592+source = "registry+https://github.com/rust-lang/crates.io-index"1593+checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"1594+dependencies = [1595+ "cfg-if",1596+ "futures-util",1597+ "hickory-net",1598+ "hickory-proto",1599+ "ipconfig",1600+ "ipnet",1601+ "jni 0.22.4",1602+ "moka",1603+ "ndk-context",1604+ "once_cell",1605+ "parking_lot",1606+ "rand 0.10.2",1607+ "resolv-conf",1608+ "rustls",1609+ "smallvec",1610+ "system-configuration",1611+ "thiserror 2.0.20",1612+ "tokio",1613+ "tokio-rustls",1614+ "tracing",1615+]1616+1617+[[package]]1618+name = "http"1619+version = "0.2.12"1620+source = "registry+https://github.com/rust-lang/crates.io-index"1621+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"1622+dependencies = [1623+ "bytes",1624+ "fnv",1625+ "itoa",1626+]1627+1628+[[package]]1629+name = "http"1630+version = "1.5.0"1631+source = "registry+https://github.com/rust-lang/crates.io-index"1632+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"1633+dependencies = [1634+ "bytes",1635+ "itoa",1636+]1637+1638+[[package]]1639+name = "http-body"1640+version = "1.1.0"1641+source = "registry+https://github.com/rust-lang/crates.io-index"1642+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"1643+dependencies = [1644+ "bytes",1645+ "http 1.5.0",1646+]1647+1648+[[package]]1649+name = "http-body-util"1650+version = "0.1.4"1651+source = "registry+https://github.com/rust-lang/crates.io-index"1652+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"1653+dependencies = [1654+ "bytes",1655+ "futures-core",1656+ "http 1.5.0",1657+ "http-body",1658+ "pin-project-lite",1659+]1660+1661+[[package]]1662+name = "http-range-header"1663+version = "0.4.2"1664+source = "registry+https://github.com/rust-lang/crates.io-index"1665+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"1666+1667+[[package]]1668+name = "httparse"1669+version = "1.10.1"1670+source = "registry+https://github.com/rust-lang/crates.io-index"1671+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"1672+1673+[[package]]1674+name = "httpdate"1675+version = "1.0.3"1676+source = "registry+https://github.com/rust-lang/crates.io-index"1677+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"1678+1679+[[package]]1680+name = "hybrid-array"1681+version = "0.4.14"1682+source = "registry+https://github.com/rust-lang/crates.io-index"1683+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"1684+dependencies = [1685+ "typenum",1686+]1687+1688+[[package]]1689+name = "hyper"1690+version = "1.11.0"1691+source = "registry+https://github.com/rust-lang/crates.io-index"1692+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"1693+dependencies = [1694+ "atomic-waker",1695+ "bytes",1696+ "futures-channel",1697+ "futures-core",1698+ "h2",1699+ "http 1.5.0",1700+ "http-body",1701+ "httparse",1702+ "httpdate",1703+ "itoa",1704+ "pin-project-lite",1705+ "smallvec",1706+ "tokio",1707+ "want",1708+]1709+1710+[[package]]1711+name = "hyper-rustls"1712+version = "0.27.9"1713+source = "registry+https://github.com/rust-lang/crates.io-index"1714+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"1715+dependencies = [1716+ "http 1.5.0",1717+ "hyper",1718+ "hyper-util",1719+ "rustls",1720+ "tokio",1721+ "tokio-rustls",1722+ "tower-service",1723+ "webpki-roots",1724+]1725+1726+[[package]]1727+name = "hyper-util"1728+version = "0.1.20"1729+source = "registry+https://github.com/rust-lang/crates.io-index"1730+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"1731+dependencies = [1732+ "base64",1733+ "bytes",1734+ "futures-channel",1735+ "futures-util",1736+ "http 1.5.0",1737+ "http-body",1738+ "hyper",1739+ "ipnet",1740+ "libc",1741+ "percent-encoding",1742+ "pin-project-lite",1743+ "socket2",1744+ "tokio",1745+ "tower-service",1746+ "tracing",1747+]1748+1749+[[package]]1750+name = "iana-time-zone"1751+version = "0.1.65"1752+source = "registry+https://github.com/rust-lang/crates.io-index"1753+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"1754+dependencies = [1755+ "android_system_properties",1756+ "core-foundation-sys",1757+ "iana-time-zone-haiku",1758+ "js-sys",1759+ "log",1760+ "wasm-bindgen",1761+ "windows-core",1762+]1763+1764+[[package]]1765+name = "iana-time-zone-haiku"1766+version = "0.1.2"1767+source = "registry+https://github.com/rust-lang/crates.io-index"1768+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"1769+dependencies = [1770+ "cc",1771+]1772+1773+[[package]]1774+name = "icu_collections"1775+version = "2.2.0"1776+source = "registry+https://github.com/rust-lang/crates.io-index"1777+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"1778+dependencies = [1779+ "displaydoc",1780+ "potential_utf",1781+ "utf8_iter",1782+ "yoke",1783+ "zerofrom",1784+ "zerovec",1785+]1786+1787+[[package]]1788+name = "icu_locale_core"1789+version = "2.2.0"1790+source = "registry+https://github.com/rust-lang/crates.io-index"1791+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"1792+dependencies = [1793+ "displaydoc",1794+ "litemap",1795+ "tinystr",1796+ "writeable",1797+ "zerovec",1798+]1799+1800+[[package]]1801+name = "icu_normalizer"1802+version = "2.2.0"1803+source = "registry+https://github.com/rust-lang/crates.io-index"1804+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"1805+dependencies = [1806+ "icu_collections",1807+ "icu_normalizer_data",1808+ "icu_properties",1809+ "icu_provider",1810+ "smallvec",1811+ "zerovec",1812+]1813+1814+[[package]]1815+name = "icu_normalizer_data"1816+version = "2.2.0"1817+source = "registry+https://github.com/rust-lang/crates.io-index"1818+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"1819+1820+[[package]]1821+name = "icu_properties"1822+version = "2.2.0"1823+source = "registry+https://github.com/rust-lang/crates.io-index"1824+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"1825+dependencies = [1826+ "icu_collections",1827+ "icu_locale_core",1828+ "icu_properties_data",1829+ "icu_provider",1830+ "zerotrie",1831+ "zerovec",1832+]1833+1834+[[package]]1835+name = "icu_properties_data"1836+version = "2.2.0"1837+source = "registry+https://github.com/rust-lang/crates.io-index"1838+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"1839+1840+[[package]]1841+name = "icu_provider"1842+version = "2.2.0"1843+source = "registry+https://github.com/rust-lang/crates.io-index"1844+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"1845+dependencies = [1846+ "displaydoc",1847+ "icu_locale_core",1848+ "writeable",1849+ "yoke",1850+ "zerofrom",1851+ "zerotrie",1852+ "zerovec",1853+]1854+1855+[[package]]1856+name = "identity-hash"1857+version = "0.1.0"1858+source = "registry+https://github.com/rust-lang/crates.io-index"1859+checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da"1860+1861+[[package]]1862+name = "idna"1863+version = "1.1.0"1864+source = "registry+https://github.com/rust-lang/crates.io-index"1865+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"1866+dependencies = [1867+ "idna_adapter",1868+ "smallvec",1869+ "utf8_iter",1870+]1871+1872+[[package]]1873+name = "idna_adapter"1874+version = "1.2.2"1875+source = "registry+https://github.com/rust-lang/crates.io-index"1876+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"1877+dependencies = [1878+ "icu_normalizer",1879+ "icu_properties",1880+]1881+1882+[[package]]1883+name = "igd-next"1884+version = "0.17.1"1885+source = "registry+https://github.com/rust-lang/crates.io-index"1886+checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581"1887+dependencies = [1888+ "attohttpc",1889+ "bytes",1890+ "futures",1891+ "http 1.5.0",1892+ "http-body-util",1893+ "hyper",1894+ "hyper-util",1895+ "log",1896+ "rand 0.10.2",1897+ "tokio",1898+ "url",1899+ "xmltree",1900+]1901+1902+[[package]]1903+name = "indexmap"1904+version = "2.14.0"1905+source = "registry+https://github.com/rust-lang/crates.io-index"1906+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"1907+dependencies = [1908+ "equivalent",1909+ "hashbrown 0.17.1",1910+]1911+1912+[[package]]1913+name = "inout"1914+version = "0.1.4"1915+source = "registry+https://github.com/rust-lang/crates.io-index"1916+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"1917+dependencies = [1918+ "generic-array",1919+]1920+1921+[[package]]1922+name = "ipconfig"1923+version = "0.3.4"1924+source = "registry+https://github.com/rust-lang/crates.io-index"1925+checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"1926+dependencies = [1927+ "socket2",1928+ "widestring",1929+ "windows-registry",1930+ "windows-result",1931+ "windows-sys 0.61.2",1932+]1933+1934+[[package]]1935+name = "ipnet"1936+version = "2.12.1"1937+source = "registry+https://github.com/rust-lang/crates.io-index"1938+checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"1939+dependencies = [1940+ "serde",1941+]1942+1943+[[package]]1944+name = "iroh"1945+version = "1.0.3"1946+source = "registry+https://github.com/rust-lang/crates.io-index"1947+checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72"1948+dependencies = [1949+ "backon",1950+ "blake3",1951+ "bytes",1952+ "cfg_aliases",1953+ "ctutils",1954+ "data-encoding",1955+ "derive_more",1956+ "ed25519-dalek 3.0.0",1957+ "futures-util",1958+ "getrandom 0.4.3",1959+ "hickory-resolver",1960+ "http 1.5.0",1961+ "ipnet",1962+ "iroh-base",1963+ "iroh-dns",1964+ "iroh-metrics",1965+ "iroh-relay",1966+ "n0-error",1967+ "n0-future",1968+ "n0-watcher",1969+ "netwatch",1970+ "noq",1971+ "noq-proto",1972+ "noq-udp",1973+ "papaya",1974+ "pin-project",1975+ "portable-atomic",1976+ "portmapper",1977+ "rand 0.10.2",1978+ "reqwest 0.13.4",1979+ "rustc-hash",1980+ "rustls",1981+ "rustls-pki-types",1982+ "serde",1983+ "smallvec",1984+ "strum",1985+ "time",1986+ "tokio",1987+ "tokio-stream",1988+ "tokio-util",1989+ "tracing",1990+ "url",1991+ "wasm-bindgen-futures",1992+]1993+1994+[[package]]1995+name = "iroh-base"1996+version = "1.0.3"1997+source = "registry+https://github.com/rust-lang/crates.io-index"1998+checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"1999+dependencies = [2000+ "curve25519-dalek 5.0.0",2001+ "data-encoding",2002+ "data-encoding-macro",2003+ "derive_more",2004+ "ed25519-dalek 3.0.0",2005+ "getrandom 0.4.3",2006+ "n0-error",2007+ "rand 0.10.2",2008+ "serde",2009+ "url",2010+ "zeroize",2011+]2012+2013+[[package]]2014+name = "iroh-dns"2015+version = "1.0.3"2016+source = "registry+https://github.com/rust-lang/crates.io-index"2017+checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349"2018+dependencies = [2019+ "arc-swap",2020+ "cfg_aliases",2021+ "derive_more",2022+ "hickory-resolver",2023+ "iroh-base",2024+ "n0-error",2025+ "n0-future",2026+ "ndk-context",2027+ "portable-atomic",2028+ "rand 0.10.2",2029+ "rustls",2030+ "simple-dns",2031+ "strum",2032+ "tokio",2033+ "tracing",2034+ "url",2035+]2036+2037+[[package]]2038+name = "iroh-metrics"2039+version = "1.0.1"2040+source = "registry+https://github.com/rust-lang/crates.io-index"2041+checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"2042+dependencies = [2043+ "iroh-metrics-derive",2044+ "itoa",2045+ "n0-error",2046+ "portable-atomic",2047+ "ryu",2048+ "serde",2049+ "tracing",2050+]2051+2052+[[package]]2053+name = "iroh-metrics-derive"2054+version = "1.0.1"2055+source = "registry+https://github.com/rust-lang/crates.io-index"2056+checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48"2057+dependencies = [2058+ "heck",2059+ "proc-macro2",2060+ "quote",2061+ "syn 2.0.119",2062+]2063+2064+[[package]]2065+name = "iroh-relay"2066+version = "1.0.3"2067+source = "registry+https://github.com/rust-lang/crates.io-index"2068+checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193"2069+dependencies = [2070+ "blake3",2071+ "bytes",2072+ "cfg_aliases",2073+ "data-encoding",2074+ "derive_more",2075+ "getrandom 0.4.3",2076+ "hickory-resolver",2077+ "http 1.5.0",2078+ "http-body-util",2079+ "hyper",2080+ "hyper-util",2081+ "iroh-base",2082+ "iroh-dns",2083+ "iroh-metrics",2084+ "lru",2085+ "n0-error",2086+ "n0-future",2087+ "noq",2088+ "noq-proto",2089+ "num_enum",2090+ "pin-project",2091+ "postcard",2092+ "rand 0.10.2",2093+ "reqwest 0.13.4",2094+ "rustls",2095+ "rustls-pki-types",2096+ "serde",2097+ "serde_bytes",2098+ "strum",2099+ "tokio",2100+ "tokio-rustls",2101+ "tokio-util",2102+ "tokio-websockets",2103+ "tracing",2104+ "url",2105+ "webpki-roots",2106+ "ws_stream_wasm",2107+]2108+2109+[[package]]2110+name = "is_terminal_polyfill"2111+version = "1.70.2"2112+source = "registry+https://github.com/rust-lang/crates.io-index"2113+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"2114+2115+[[package]]2116+name = "itoa"2117+version = "1.0.18"2118+source = "registry+https://github.com/rust-lang/crates.io-index"2119+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"2120+2121+[[package]]2122+name = "jni"2123+version = "0.21.1"2124+source = "registry+https://github.com/rust-lang/crates.io-index"2125+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"2126+dependencies = [2127+ "cesu8",2128+ "cfg-if",2129+ "combine",2130+ "jni-sys 0.3.1",2131+ "log",2132+ "thiserror 1.0.69",2133+ "walkdir",2134+ "windows-sys 0.45.0",2135+]2136+2137+[[package]]2138+name = "jni"2139+version = "0.22.4"2140+source = "registry+https://github.com/rust-lang/crates.io-index"2141+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"2142+dependencies = [2143+ "cfg-if",2144+ "combine",2145+ "jni-macros",2146+ "jni-sys 0.4.1",2147+ "log",2148+ "simd_cesu8",2149+ "thiserror 2.0.20",2150+ "walkdir",2151+ "windows-link",2152+]2153+2154+[[package]]2155+name = "jni-macros"2156+version = "0.22.4"2157+source = "registry+https://github.com/rust-lang/crates.io-index"2158+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"2159+dependencies = [2160+ "proc-macro2",2161+ "quote",2162+ "rustc_version",2163+ "simd_cesu8",2164+ "syn 2.0.119",2165+]2166+2167+[[package]]2168+name = "jni-sys"2169+version = "0.3.1"2170+source = "registry+https://github.com/rust-lang/crates.io-index"2171+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"2172+dependencies = [2173+ "jni-sys 0.4.1",2174+]2175+2176+[[package]]2177+name = "jni-sys"2178+version = "0.4.1"2179+source = "registry+https://github.com/rust-lang/crates.io-index"2180+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"2181+dependencies = [2182+ "jni-sys-macros",2183+]2184+2185+[[package]]2186+name = "jni-sys-macros"2187+version = "0.4.1"2188+source = "registry+https://github.com/rust-lang/crates.io-index"2189+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"2190+dependencies = [2191+ "quote",2192+ "syn 2.0.119",2193+]2194+2195+[[package]]2196+name = "js-sys"2197+version = "0.3.104"2198+source = "registry+https://github.com/rust-lang/crates.io-index"2199+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"2200+dependencies = [2201+ "cfg-if",2202+ "futures-util",2203+ "wasm-bindgen",2204+]2205+2206+[[package]]2207+name = "jsonwebtoken"2208+version = "9.3.1"2209+source = "registry+https://github.com/rust-lang/crates.io-index"2210+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"2211+dependencies = [2212+ "base64",2213+ "js-sys",2214+ "pem",2215+ "ring",2216+ "serde",2217+ "serde_json",2218+ "simple_asn1",2219+]2220+2221+[[package]]2222+name = "lazy_static"2223+version = "1.5.0"2224+source = "registry+https://github.com/rust-lang/crates.io-index"2225+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"2226+2227+[[package]]2228+name = "libc"2229+version = "0.2.189"2230+source = "registry+https://github.com/rust-lang/crates.io-index"2231+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"2232+2233+[[package]]2234+name = "libredox"2235+version = "0.1.19"2236+source = "registry+https://github.com/rust-lang/crates.io-index"2237+checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa"2238+dependencies = [2239+ "libc",2240+]2241+2242+[[package]]2243+name = "libsqlite3-sys"2244+version = "0.35.0"2245+source = "registry+https://github.com/rust-lang/crates.io-index"2246+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"2247+dependencies = [2248+ "cc",2249+ "pkg-config",2250+ "vcpkg",2251+]2252+2253+[[package]]2254+name = "litemap"2255+version = "0.8.2"2256+source = "registry+https://github.com/rust-lang/crates.io-index"2257+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"2258+2259+[[package]]2260+name = "lock_api"2261+version = "0.4.14"2262+source = "registry+https://github.com/rust-lang/crates.io-index"2263+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"2264+dependencies = [2265+ "scopeguard",2266+]2267+2268+[[package]]2269+name = "log"2270+version = "0.4.33"2271+source = "registry+https://github.com/rust-lang/crates.io-index"2272+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"2273+2274+[[package]]2275+name = "loom"2276+version = "0.7.2"2277+source = "registry+https://github.com/rust-lang/crates.io-index"2278+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"2279+dependencies = [2280+ "cfg-if",2281+ "generator",2282+ "scoped-tls",2283+ "tracing",2284+ "tracing-subscriber",2285+]2286+2287+[[package]]2288+name = "lru"2289+version = "0.18.2"2290+source = "registry+https://github.com/rust-lang/crates.io-index"2291+checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"2292+dependencies = [2293+ "hashbrown 0.17.1",2294+]2295+2296+[[package]]2297+name = "lru-slab"2298+version = "0.1.2"2299+source = "registry+https://github.com/rust-lang/crates.io-index"2300+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"2301+2302+[[package]]2303+name = "mac-addr"2304+version = "0.3.0"2305+source = "registry+https://github.com/rust-lang/crates.io-index"2306+checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"2307+2308+[[package]]2309+name = "matchers"2310+version = "0.2.0"2311+source = "registry+https://github.com/rust-lang/crates.io-index"2312+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"2313+dependencies = [2314+ "regex-automata",2315+]2316+2317+[[package]]2318+name = "matchit"2319+version = "0.8.4"2320+source = "registry+https://github.com/rust-lang/crates.io-index"2321+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"2322+2323+[[package]]2324+name = "memchr"2325+version = "2.8.3"2326+source = "registry+https://github.com/rust-lang/crates.io-index"2327+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"2328+2329+[[package]]2330+name = "mime"2331+version = "0.3.17"2332+source = "registry+https://github.com/rust-lang/crates.io-index"2333+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"2334+2335+[[package]]2336+name = "mime_guess"2337+version = "2.0.5"2338+source = "registry+https://github.com/rust-lang/crates.io-index"2339+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"2340+dependencies = [2341+ "mime",2342+ "unicase",2343+]2344+2345+[[package]]2346+name = "mio"2347+version = "1.2.2"2348+source = "registry+https://github.com/rust-lang/crates.io-index"2349+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"2350+dependencies = [2351+ "libc",2352+ "wasi",2353+ "windows-sys 0.61.2",2354+]2355+2356+[[package]]2357+name = "moka"2358+version = "0.12.16"2359+source = "registry+https://github.com/rust-lang/crates.io-index"2360+checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"2361+dependencies = [2362+ "crossbeam-channel",2363+ "crossbeam-epoch",2364+ "crossbeam-utils",2365+ "equivalent",2366+ "parking_lot",2367+ "portable-atomic",2368+ "smallvec",2369+ "tagptr",2370+ "uuid",2371+]2372+2373+[[package]]2374+name = "n0-error"2375+version = "1.0.0"2376+source = "registry+https://github.com/rust-lang/crates.io-index"2377+checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895"2378+dependencies = [2379+ "n0-error-macros",2380+ "spez",2381+]2382+2383+[[package]]2384+name = "n0-error-macros"2385+version = "1.0.0"2386+source = "registry+https://github.com/rust-lang/crates.io-index"2387+checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1"2388+dependencies = [2389+ "proc-macro2",2390+ "quote",2391+ "syn 2.0.119",2392+]2393+2394+[[package]]2395+name = "n0-future"2396+version = "0.3.2"2397+source = "registry+https://github.com/rust-lang/crates.io-index"2398+checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe"2399+dependencies = [2400+ "cfg_aliases",2401+ "derive_more",2402+ "futures-buffered",2403+ "futures-lite",2404+ "futures-util",2405+ "js-sys",2406+ "pin-project",2407+ "send_wrapper",2408+ "tokio",2409+ "tokio-util",2410+ "wasm-bindgen",2411+ "wasm-bindgen-futures",2412+ "web-time",2413+]2414+2415+[[package]]2416+name = "n0-watcher"2417+version = "1.0.0"2418+source = "registry+https://github.com/rust-lang/crates.io-index"2419+checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826"2420+dependencies = [2421+ "derive_more",2422+ "n0-error",2423+ "n0-future",2424+]2425+2426+[[package]]2427+name = "ndk-context"2428+version = "0.1.1"2429+source = "registry+https://github.com/rust-lang/crates.io-index"2430+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"2431+2432+[[package]]2433+name = "netdev"2434+version = "0.45.0"2435+source = "registry+https://github.com/rust-lang/crates.io-index"2436+checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020"2437+dependencies = [2438+ "block2",2439+ "dispatch2",2440+ "dlopen2",2441+ "ipnet",2442+ "jni 0.21.1",2443+ "libc",2444+ "mac-addr",2445+ "ndk-context",2446+ "netlink-packet-core",2447+ "netlink-packet-route",2448+ "netlink-sys",2449+ "objc2",2450+ "objc2-core-foundation",2451+ "objc2-core-wlan",2452+ "objc2-foundation",2453+ "objc2-system-configuration",2454+ "once_cell",2455+ "plist",2456+ "windows-sys 0.61.2",2457+]2458+2459+[[package]]2460+name = "netlink-packet-core"2461+version = "0.8.2"2462+source = "registry+https://github.com/rust-lang/crates.io-index"2463+checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395"2464+dependencies = [2465+ "paste",2466+]2467+2468+[[package]]2469+name = "netlink-packet-route"2470+version = "0.31.0"2471+source = "registry+https://github.com/rust-lang/crates.io-index"2472+checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007"2473+dependencies = [2474+ "bitflags",2475+ "libc",2476+ "log",2477+ "netlink-packet-core",2478+]2479+2480+[[package]]2481+name = "netlink-proto"2482+version = "0.12.1"2483+source = "registry+https://github.com/rust-lang/crates.io-index"2484+checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6"2485+dependencies = [2486+ "bytes",2487+ "futures-channel",2488+ "futures-util",2489+ "log",2490+ "netlink-packet-core",2491+ "netlink-sys",2492+ "thiserror 2.0.20",2493+]2494+2495+[[package]]2496+name = "netlink-sys"2497+version = "0.8.8"2498+source = "registry+https://github.com/rust-lang/crates.io-index"2499+checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae"2500+dependencies = [2501+ "bytes",2502+ "futures-util",2503+ "libc",2504+ "log",2505+ "tokio",2506+]2507+2508+[[package]]2509+name = "netwatch"2510+version = "0.19.1"2511+source = "registry+https://github.com/rust-lang/crates.io-index"2512+checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976"2513+dependencies = [2514+ "atomic-waker",2515+ "bytes",2516+ "cfg_aliases",2517+ "derive_more",2518+ "ipnet",2519+ "js-sys",2520+ "libc",2521+ "n0-error",2522+ "n0-future",2523+ "n0-watcher",2524+ "netdev",2525+ "netlink-packet-core",2526+ "netlink-packet-route",2527+ "netlink-proto",2528+ "netlink-sys",2529+ "noq-udp",2530+ "objc2-core-foundation",2531+ "objc2-system-configuration",2532+ "pin-project-lite",2533+ "serde",2534+ "socket2",2535+ "time",2536+ "tokio",2537+ "tokio-util",2538+ "tracing",2539+ "web-sys",2540+ "windows",2541+ "windows-result",2542+ "wmi",2543+]2544+2545+[[package]]2546+name = "noq"2547+version = "1.1.1"2548+source = "registry+https://github.com/rust-lang/crates.io-index"2549+checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da"2550+dependencies = [2551+ "bytes",2552+ "cfg_aliases",2553+ "derive_more",2554+ "noq-proto",2555+ "noq-udp",2556+ "pin-project-lite",2557+ "rustc-hash",2558+ "rustls",2559+ "socket2",2560+ "thiserror 2.0.20",2561+ "tokio",2562+ "tokio-stream",2563+ "tracing",2564+ "web-time",2565+]2566+2567+[[package]]2568+name = "noq-proto"2569+version = "1.1.1"2570+source = "registry+https://github.com/rust-lang/crates.io-index"2571+checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15"2572+dependencies = [2573+ "aes-gcm",2574+ "bytes",2575+ "derive_more",2576+ "enum-assoc",2577+ "getrandom 0.4.3",2578+ "identity-hash",2579+ "lru-slab",2580+ "rand 0.10.2",2581+ "rand_pcg",2582+ "ring",2583+ "rustc-hash",2584+ "rustls",2585+ "rustls-pki-types",2586+ "slab",2587+ "sorted-index-buffer",2588+ "thiserror 2.0.20",2589+ "tinyvec",2590+ "tracing",2591+ "web-time",2592+]2593+2594+[[package]]2595+name = "noq-udp"2596+version = "1.1.1"2597+source = "registry+https://github.com/rust-lang/crates.io-index"2598+checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647"2599+dependencies = [2600+ "cfg_aliases",2601+ "libc",2602+ "socket2",2603+ "tracing",2604+ "windows-sys 0.61.2",2605+]2606+2607+[[package]]2608+name = "nu-ansi-term"2609+version = "0.50.3"2610+source = "registry+https://github.com/rust-lang/crates.io-index"2611+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"2612+dependencies = [2613+ "windows-sys 0.61.2",2614+]2615+2616+[[package]]2617+name = "num-bigint"2618+version = "0.4.8"2619+source = "registry+https://github.com/rust-lang/crates.io-index"2620+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"2621+dependencies = [2622+ "num-integer",2623+ "num-traits",2624+]2625+2626+[[package]]2627+name = "num-conv"2628+version = "0.2.2"2629+source = "registry+https://github.com/rust-lang/crates.io-index"2630+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"2631+2632+[[package]]2633+name = "num-integer"2634+version = "0.1.46"2635+source = "registry+https://github.com/rust-lang/crates.io-index"2636+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"2637+dependencies = [2638+ "num-traits",2639+]2640+2641+[[package]]2642+name = "num-traits"2643+version = "0.2.19"2644+source = "registry+https://github.com/rust-lang/crates.io-index"2645+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"2646+dependencies = [2647+ "autocfg",2648+]2649+2650+[[package]]2651+name = "num_enum"2652+version = "0.7.6"2653+source = "registry+https://github.com/rust-lang/crates.io-index"2654+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"2655+dependencies = [2656+ "num_enum_derive",2657+ "rustversion",2658+]2659+2660+[[package]]2661+name = "num_enum_derive"2662+version = "0.7.6"2663+source = "registry+https://github.com/rust-lang/crates.io-index"2664+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"2665+dependencies = [2666+ "proc-macro-crate",2667+ "proc-macro2",2668+ "quote",2669+ "syn 2.0.119",2670+]2671+2672+[[package]]2673+name = "objc2"2674+version = "0.6.4"2675+source = "registry+https://github.com/rust-lang/crates.io-index"2676+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"2677+dependencies = [2678+ "objc2-encode",2679+]2680+2681+[[package]]2682+name = "objc2-core-foundation"2683+version = "0.3.2"2684+source = "registry+https://github.com/rust-lang/crates.io-index"2685+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"2686+dependencies = [2687+ "bitflags",2688+ "block2",2689+ "dispatch2",2690+ "libc",2691+ "objc2",2692+]2693+2694+[[package]]2695+name = "objc2-core-wlan"2696+version = "0.3.2"2697+source = "registry+https://github.com/rust-lang/crates.io-index"2698+checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48"2699+dependencies = [2700+ "bitflags",2701+ "objc2",2702+ "objc2-core-foundation",2703+ "objc2-foundation",2704+ "objc2-security",2705+ "objc2-security-foundation",2706+]2707+2708+[[package]]2709+name = "objc2-encode"2710+version = "4.1.0"2711+source = "registry+https://github.com/rust-lang/crates.io-index"2712+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"2713+2714+[[package]]2715+name = "objc2-foundation"2716+version = "0.3.2"2717+source = "registry+https://github.com/rust-lang/crates.io-index"2718+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"2719+dependencies = [2720+ "bitflags",2721+ "block2",2722+ "libc",2723+ "objc2",2724+ "objc2-core-foundation",2725+]2726+2727+[[package]]2728+name = "objc2-security"2729+version = "0.3.2"2730+source = "registry+https://github.com/rust-lang/crates.io-index"2731+checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"2732+dependencies = [2733+ "bitflags",2734+ "objc2",2735+ "objc2-core-foundation",2736+]2737+2738+[[package]]2739+name = "objc2-security-foundation"2740+version = "0.3.2"2741+source = "registry+https://github.com/rust-lang/crates.io-index"2742+checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5"2743+dependencies = [2744+ "objc2",2745+ "objc2-foundation",2746+]2747+2748+[[package]]2749+name = "objc2-system-configuration"2750+version = "0.3.2"2751+source = "registry+https://github.com/rust-lang/crates.io-index"2752+checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"2753+dependencies = [2754+ "bitflags",2755+ "dispatch2",2756+ "libc",2757+ "objc2",2758+ "objc2-core-foundation",2759+ "objc2-security",2760+]2761+2762+[[package]]2763+name = "once_cell"2764+version = "1.21.4"2765+source = "registry+https://github.com/rust-lang/crates.io-index"2766+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"2767+dependencies = [2768+ "critical-section",2769+ "portable-atomic",2770+]2771+2772+[[package]]2773+name = "once_cell_polyfill"2774+version = "1.70.2"2775+source = "registry+https://github.com/rust-lang/crates.io-index"2776+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"2777+2778+[[package]]2779+name = "opaque-debug"2780+version = "0.3.1"2781+source = "registry+https://github.com/rust-lang/crates.io-index"2782+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"2783+2784+[[package]]2785+name = "openssl-probe"2786+version = "0.2.1"2787+source = "registry+https://github.com/rust-lang/crates.io-index"2788+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"2789+2790+[[package]]2791+name = "option-ext"2792+version = "0.2.0"2793+source = "registry+https://github.com/rust-lang/crates.io-index"2794+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"2795+2796+[[package]]2797+name = "papaya"2798+version = "0.2.4"2799+source = "registry+https://github.com/rust-lang/crates.io-index"2800+checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7"2801+dependencies = [2802+ "equivalent",2803+ "seize",2804+]2805+2806+[[package]]2807+name = "parking"2808+version = "2.2.1"2809+source = "registry+https://github.com/rust-lang/crates.io-index"2810+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"2811+2812+[[package]]2813+name = "parking_lot"2814+version = "0.12.5"2815+source = "registry+https://github.com/rust-lang/crates.io-index"2816+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"2817+dependencies = [2818+ "lock_api",2819+ "parking_lot_core",2820+]2821+2822+[[package]]2823+name = "parking_lot_core"2824+version = "0.9.12"2825+source = "registry+https://github.com/rust-lang/crates.io-index"2826+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"2827+dependencies = [2828+ "cfg-if",2829+ "libc",2830+ "redox_syscall",2831+ "smallvec",2832+ "windows-link",2833+]2834+2835+[[package]]2836+name = "paste"2837+version = "1.0.15"2838+source = "registry+https://github.com/rust-lang/crates.io-index"2839+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"2840+2841+[[package]]2842+name = "pem"2843+version = "3.0.6"2844+source = "registry+https://github.com/rust-lang/crates.io-index"2845+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"2846+dependencies = [2847+ "base64",2848+ "serde_core",2849+]2850+2851+[[package]]2852+name = "pem-rfc7468"2853+version = "1.0.0"2854+source = "registry+https://github.com/rust-lang/crates.io-index"2855+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"2856+dependencies = [2857+ "base64ct",2858+]2859+2860+[[package]]2861+name = "percent-encoding"2862+version = "2.3.2"2863+source = "registry+https://github.com/rust-lang/crates.io-index"2864+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"2865+2866+[[package]]2867+name = "pharos"2868+version = "0.5.3"2869+source = "registry+https://github.com/rust-lang/crates.io-index"2870+checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414"2871+dependencies = [2872+ "futures",2873+ "rustc_version",2874+]2875+2876+[[package]]2877+name = "pin-project"2878+version = "1.1.13"2879+source = "registry+https://github.com/rust-lang/crates.io-index"2880+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"2881+dependencies = [2882+ "pin-project-internal",2883+]2884+2885+[[package]]2886+name = "pin-project-internal"2887+version = "1.1.13"2888+source = "registry+https://github.com/rust-lang/crates.io-index"2889+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"2890+dependencies = [2891+ "proc-macro2",2892+ "quote",2893+ "syn 2.0.119",2894+]2895+2896+[[package]]2897+name = "pin-project-lite"2898+version = "0.2.17"2899+source = "registry+https://github.com/rust-lang/crates.io-index"2900+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"2901+2902+[[package]]2903+name = "pkcs8"2904+version = "0.10.2"2905+source = "registry+https://github.com/rust-lang/crates.io-index"2906+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"2907+dependencies = [2908+ "der 0.7.10",2909+ "spki 0.7.3",2910+]2911+2912+[[package]]2913+name = "pkcs8"2914+version = "0.11.0"2915+source = "registry+https://github.com/rust-lang/crates.io-index"2916+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"2917+dependencies = [2918+ "der 0.8.1",2919+ "spki 0.8.0",2920+]2921+2922+[[package]]2923+name = "pkg-config"2924+version = "0.3.33"2925+source = "registry+https://github.com/rust-lang/crates.io-index"2926+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"2927+2928+[[package]]2929+name = "plist"2930+version = "1.10.0"2931+source = "registry+https://github.com/rust-lang/crates.io-index"2932+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"2933+dependencies = [2934+ "base64",2935+ "indexmap",2936+ "quick-xml",2937+ "serde",2938+ "time",2939+]2940+2941+[[package]]2942+name = "polyval"2943+version = "0.6.2"2944+source = "registry+https://github.com/rust-lang/crates.io-index"2945+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"2946+dependencies = [2947+ "cfg-if",2948+ "cpufeatures 0.2.17",2949+ "opaque-debug",2950+ "universal-hash",2951+]2952+2953+[[package]]2954+name = "portable-atomic"2955+version = "1.15.0"2956+source = "registry+https://github.com/rust-lang/crates.io-index"2957+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"2958+dependencies = [2959+ "serde",2960+]2961+2962+[[package]]2963+name = "portmapper"2964+version = "0.19.1"2965+source = "registry+https://github.com/rust-lang/crates.io-index"2966+checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721"2967+dependencies = [2968+ "base64",2969+ "bytes",2970+ "derive_more",2971+ "hyper-util",2972+ "igd-next",2973+ "iroh-metrics",2974+ "libc",2975+ "n0-error",2976+ "n0-future",2977+ "netwatch",2978+ "num_enum",2979+ "rand 0.10.2",2980+ "serde",2981+ "smallvec",2982+ "socket2",2983+ "time",2984+ "tokio",2985+ "tokio-util",2986+ "tower-layer",2987+ "tracing",2988+ "url",2989+]2990+2991+[[package]]2992+name = "postcard"2993+version = "1.1.3"2994+source = "registry+https://github.com/rust-lang/crates.io-index"2995+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"2996+dependencies = [2997+ "cobs",2998+ "embedded-io 0.4.0",2999+ "embedded-io 0.6.1",3000+ "postcard-derive",
diff truncated
A Cargo.toml +27 -0
A README.md +129 -0
@@ -0,0 +1,129 @@
1+# dev.site2+3+A developer profile that mixes ordinary public links with private, locally-hosted services —4+reachable from a browser without deploying those services or opening a port.5+6+Status: **Live at https://dev.site.** The full vertical slice works end to end — public7+links, owner-private local services, person-to-person sharing, centralized authorization,8+browser access over an encrypted relay, and no port forwarding anywhere. Verified with two9+real Google accounts. See `docs/milestones.md`.10+11+## How it works12+13+```14+browser (ephemeral Iroh key)15+ │ 1. POST /api/capability {resource_id, browser_endpoint_id}16+ ▼17+dev.site control plane ── authorizes, signs a short-lived capability (Ed25519)18+ │ 2. {capability, daemon endpoint id, relay url}19+ ▼20+browser ──── 3. Iroh, end-to-end encrypted via relay ────► your daemon21+ │ 4. verify22+ ▼ signature · audience ·23+ expiry · browser key vs24+ the authenticated peer ·25+ resource in local config26+ 127.0.0.1:410127+```28+29+The browser never names an upstream URL. The daemon resolves the origin from its own30+config, keyed by resource id — that is what keeps it from being an open proxy. The control31+plane holds permissions but never carries service traffic.32+33+## Prerequisites34+35+- Rust stable + `rustup target add wasm32-unknown-unknown`36+- `cargo install wasm-pack`37+- A clang with the WebAssembly backend (`ring` compiles C). Apple's system clang does not38+ have one — on macOS run `brew install llvm`. `scripts/build-wasm.sh` finds it.39+40+## Running it locally41+42+```bash43+./scripts/build-wasm.sh44+45+export DEVSITE_PUBLIC_ORIGIN=http://127.0.0.1:4000 # pins account identity, see below46+cargo run -p devsite-server47+48+cargo run -p devsite-fixture -- 4101 fixtures/page-a.html # Hermes49+cargo run -p devsite-fixture -- 4102 fixtures/page-b.html # Agent50+```51+52+Sign in at <http://127.0.0.1:4000> (Google, via Shoo), or mint a session from the shell:53+54+```bash55+cargo run -p devsite-server -- issue-session dami56+```57+58+Then configure the machine:59+60+```bash61+devsite login --token <token>62+devsite profile create @dami63+devsite link add --name "Klot Ski" --url https://klot.ski --public64+devsite expose http://127.0.0.1:4101 --name "Hermes" --private65+devsite expose http://127.0.0.1:4102 --name "Agent" --share @frank66+devsite daemon run67+```68+69+### Configuration70+71+| Variable | Default | Notes |72+| --- | --- | --- |73+| `DEVSITE_PUBLIC_ORIGIN` | *(required)* | The exact origin browsers load. **Changing it orphans every account** — Shoo derives both `client_id` and the pairwise subject from it. |74+| `DEVSITE_BIND` | `127.0.0.1:4000` | |75+| `DEVSITE_DB` | `devsite.db` | |76+| `DEVSITE_STATE_DIR` | `.devsite-state` | Holds the capability signing key. |77+| `DEVSITE_HOME` | platform config dir | Daemon identity and config. Set it to run several daemons on one machine. |78+79+## Deploying80+81+`deploy/` publishes the control plane at `https://dev.site` through Cloudflare Tunnel — so82+it has no inbound port either. See `deploy/README.md`.83+84+```bash85+cloudflared tunnel login # once, opens a browser86+./deploy/setup-tunnel.sh # creates the tunnel + DNS87+./deploy/run.sh # control plane + tunnel88+```89+90+## Layout91+92+| Path | What it is |93+| --- | --- |94+| `crates/devsite-proto` | Ids, capability token, wire frames. No I/O; compiles for wasm. |95+| `crates/devsite-client` | Viewer side of the data plane. Compiles native **and** wasm. |96+| `crates/devsite-web` | wasm-bindgen seam over `devsite-client`. |97+| `crates/devsite-daemon` | Iroh endpoint, capability verification, loopback proxy. |98+| `crates/devsite-server` | Control plane: axum + SQLite, Shoo auth, capability issuance. |99+| `crates/devsite-cli` | `devsite` — login, profile, link, expose, daemon run. |100+| `crates/devsite-fixture` | Serves one HTML file on a port; stands in for a real service. |101+| `web/` | The site. `web/pkg/` is generated by `scripts/build-wasm.sh`. |102+103+`devsite-client` compiling both ways is deliberate: the authorization matrix is proven by104+fast native tests running the exact code the browser executes.105+106+## Testing107+108+```bash109+cargo test --workspace110+```111+112+51 tests. The interesting ones are `crates/devsite-daemon/tests/authz.rs`, which drives113+real Iroh against a real local service and asserts that forged, expired, misaddressed,114+rebound, replayed and unknown-resource capabilities are each refused — and that a denial115+tells the caller nothing about which of those it was.116+117+## Security notes118+119+- Capabilities are bound to a browser endpoint key and checked against the connection's120+ authenticated peer, so a stolen capability is useless without the matching private key.121+- Denials are deliberately indistinguishable from one another, and `/api/capability`122+ returns 404 rather than 403, so resource ids cannot be enumerated.123+- `devsite expose` refuses public addresses: exposures are limited to loopback, private124+ ranges and `.ts.net`, so a daemon cannot be turned into a launderer for its owner's IP.125+- Fetched pages render in `sandbox="allow-scripts"` **without** `allow-same-origin`, giving126+ them an opaque origin with no access to dev.site's DOM, storage or cookies.127+- Session tokens are stored only as SHA-256 hashes.128+- `issue-session` is an operator command requiring shell access; there is no129+ network-reachable authentication bypass.
A crates/devsite-cli/Cargo.toml +24 -0
A crates/devsite-cli/src/client.rs +86 -0
@@ -0,0 +1,86 @@
1+//! Thin HTTP client for the control plane, used by the CLI and the daemon's heartbeat.2+3+use anyhow::{bail, Context, Result};4+use serde::de::DeserializeOwned;5+use serde::Serialize;6+7+pub struct ControlPlane {8+ base: String,9+ token: Option<String>,10+ http: reqwest::Client,11+}12+13+impl ControlPlane {14+ pub fn new(base: &str, token: Option<String>) -> Self {15+ Self {16+ base: base.trim_end_matches('/').to_string(),17+ token,18+ http: reqwest::Client::new(),19+ }20+ }21+22+ fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {23+ let builder = self.http.request(method, format!("{}{path}", self.base));24+ match &self.token {25+ Some(token) => builder.bearer_auth(token),26+ None => builder,27+ }28+ }29+30+ pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {31+ let response = self32+ .request(reqwest::Method::GET, path)33+ .send()34+ .await35+ .with_context(|| format!("GET {path}"))?;36+ decode(response, path).await37+ }38+39+ pub async fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {40+ let response = self41+ .request(reqwest::Method::POST, path)42+ .json(body)43+ .send()44+ .await45+ .with_context(|| format!("POST {path}"))?;46+ decode(response, path).await47+ }48+49+ /// POST where a 2xx with no body is the expected success.50+ pub async fn post_empty<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {51+ let response = self52+ .request(reqwest::Method::POST, path)53+ .json(body)54+ .send()55+ .await56+ .with_context(|| format!("POST {path}"))?;57+ if !response.status().is_success() {58+ bail!("{path} failed: {}", error_text(response).await);59+ }60+ Ok(())61+ }62+}63+64+async fn decode<T: DeserializeOwned>(response: reqwest::Response, path: &str) -> Result<T> {65+ if !response.status().is_success() {66+ bail!("{path} failed: {}", error_text(response).await);67+ }68+ response69+ .json()70+ .await71+ .with_context(|| format!("parsing the response to {path}"))72+}73+74+/// Surface the server's `error` field when there is one, so the CLI shows a real message75+/// rather than a bare status code.76+async fn error_text(response: reqwest::Response) -> String {77+ let status = response.status();78+ match response.json::<serde_json::Value>().await {79+ Ok(value) => value80+ .get("error")81+ .and_then(|e| e.as_str())82+ .map(|e| format!("{status}: {e}"))83+ .unwrap_or_else(|| format!("{status}")),84+ Err(_) => format!("{status}"),85+ }86+}
A crates/devsite-cli/src/main.rs +372 -0
@@ -0,0 +1,372 @@
1+//! `devsite` — configure a profile and expose local services.2+3+mod client;4+5+use anyhow::{bail, Context, Result};6+use clap::{Parser, Subcommand};7+use devsite_daemon::config::{validate_origin, DaemonConfig, ExposedResource, Paths, Visibility};8+use devsite_daemon::Daemon;9+use serde::Deserialize;10+use std::sync::Arc;11+use url::Url;12+13+use crate::client::ControlPlane;14+15+#[derive(Parser)]16+#[command(name = "devsite", about = "Share public work and reach your local services")]17+struct Cli {18+ /// Control plane base URL.19+ #[arg(long, env = "DEVSITE_SERVER", default_value = "https://dev.site", global = true)]20+ server: String,21+22+ #[command(subcommand)]23+ command: Command,24+}25+26+#[derive(Subcommand)]27+enum Command {28+ /// Store a session token from the website and pin the control plane's signing key.29+ Login {30+ /// Session token shown by the website after signing in.31+ #[arg(long)]32+ token: Option<String>,33+ },34+ /// Profile management.35+ #[command(subcommand)]36+ Profile(ProfileCommand),37+ /// Add an ordinary external link.38+ #[command(subcommand)]39+ Link(LinkCommand),40+ /// Expose a local service.41+ Expose {42+ /// Local origin, e.g. http://127.0.0.1:410143+ origin: Url,44+ #[arg(long)]45+ name: String,46+ #[arg(long, conflicts_with_all = ["private", "share"])]47+ public: bool,48+ #[arg(long, conflicts_with_all = ["public", "share"])]49+ private: bool,50+ /// Share with a specific user, e.g. --share @frank. Repeatable.51+ #[arg(long, value_name = "@handle")]52+ share: Vec<String>,53+ },54+ /// Show what this machine is configured to serve.55+ Status,56+ /// Daemon control.57+ #[command(subcommand)]58+ Daemon(DaemonCommand),59+}60+61+#[derive(Subcommand)]62+enum ProfileCommand {63+ /// Claim a handle, e.g. `devsite profile create @dami`.64+ Create { handle: String },65+}66+67+#[derive(Subcommand)]68+enum LinkCommand {69+ Add {70+ #[arg(long)]71+ name: String,72+ #[arg(long)]73+ url: String,74+ #[arg(long)]75+ public: bool,76+ },77+}78+79+#[derive(Subcommand)]80+enum DaemonCommand {81+ /// Run the daemon in the foreground.82+ Run,83+}84+85+#[derive(Deserialize)]86+struct PubKeyResponse {87+ public_key: String,88+}89+90+#[derive(Deserialize)]91+struct HandleResponse {92+ handle: String,93+}94+95+#[derive(Deserialize)]96+struct CreateResourceResponse {97+ resource_id: String,98+}99+100+#[tokio::main]101+async fn main() -> Result<()> {102+ tracing_subscriber::fmt()103+ .with_env_filter(104+ tracing_subscriber::EnvFilter::try_from_default_env()105+ .unwrap_or_else(|_| "devsite=info,devsite_daemon=info,warn".into()),106+ )107+ .with_target(false)108+ .init();109+110+ let cli = Cli::parse();111+ let paths = Paths::discover()?;112+113+ match cli.command {114+ Command::Login { token } => login(&paths, &cli.server, token).await,115+ Command::Profile(ProfileCommand::Create { handle }) => {116+ create_profile(&paths, &cli.server, &handle).await117+ }118+ Command::Link(LinkCommand::Add { name, url, public }) => {119+ add_link(&paths, &cli.server, &name, &url, public).await120+ }121+ Command::Expose {122+ origin,123+ name,124+ public,125+ private,126+ share,127+ } => expose(&paths, &cli.server, origin, &name, public, private, share).await,128+ Command::Status => status(&paths),129+ Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server).await,130+ }131+}132+133+fn load_authenticated(paths: &Paths) -> Result<DaemonConfig> {134+ let config = paths.load_config()?;135+ if config.session_token.is_none() {136+ bail!("not signed in — run `devsite login` first");137+ }138+ Ok(config)139+}140+141+async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()> {142+ let token = match token {143+ Some(token) => token,144+ None => {145+ println!("Sign in at {server} and paste the session token shown there.");146+ print!("token: ");147+ use std::io::Write;148+ std::io::stdout().flush()?;149+ let mut line = String::new();150+ std::io::stdin().read_line(&mut line)?;151+ line.trim().to_string()152+ }153+ };154+ if token.is_empty() {155+ bail!("no token provided");156+ }157+158+ let api = ControlPlane::new(server, Some(token.clone()));159+ // Confirm the token works before storing it, so a typo fails here rather than at the160+ // first heartbeat.161+ let me: serde_json::Value = api162+ .get("/api/me")163+ .await164+ .context("that token was not accepted")?;165+ let pubkey: PubKeyResponse = api.get("/api/pubkey").await?;166+167+ let mut config = paths.load_config()?;168+ config.server_url = Some(server.trim_end_matches('/').to_string());169+ config.session_token = Some(token);170+ // Pinned now, and every capability is checked against it from here on.171+ config.control_plane_key = Some(pubkey.public_key);172+ paths.save_config(&config)?;173+174+ let handle = me175+ .get("handle")176+ .and_then(|h| h.as_str())177+ .unwrap_or("(no handle yet)");178+ println!("signed in as {handle}");179+ println!("pinned control plane key {}", config.control_plane_key.unwrap());180+ Ok(())181+}182+183+async fn create_profile(paths: &Paths, server: &str, handle: &str) -> Result<()> {184+ let config = load_authenticated(paths)?;185+ let api = ControlPlane::new(server, config.session_token.clone());186+ let response: HandleResponse = api187+ .post(188+ "/api/profile",189+ &serde_json::json!({ "handle": handle.trim_start_matches('@') }),190+ )191+ .await?;192+ println!("profile ready at {server}/@{}", response.handle);193+ Ok(())194+}195+196+async fn add_link(paths: &Paths, server: &str, name: &str, url: &str, public: bool) -> Result<()> {197+ if !public {198+ bail!("links are external URLs and are always public; pass --public to confirm");199+ }200+ let config = load_authenticated(paths)?;201+ let api = ControlPlane::new(server, config.session_token.clone());202+ let _: CreateResourceResponse = api203+ .post(204+ "/api/resources",205+ &serde_json::json!({206+ "name": name,207+ "kind": "link",208+ "visibility": "public",209+ "url": url,210+ }),211+ )212+ .await?;213+ println!("added link {name} → {url}");214+ Ok(())215+}216+217+async fn expose(218+ paths: &Paths,219+ server: &str,220+ origin: Url,221+ name: &str,222+ public: bool,223+ private: bool,224+ share: Vec<String>,225+) -> Result<()> {226+ // Refuse to proxy to the public internet: dev.site exposes local services, and an227+ // arbitrary upstream would make the daemon a traffic launderer.228+ validate_origin(&origin)?;229+230+ let visibility = match (public, private, share.is_empty()) {231+ (true, false, true) => Visibility::Public,232+ (false, _, true) => Visibility::Private,233+ (false, false, false) => Visibility::Shared,234+ (true, _, false) => bail!("--public and --share are mutually exclusive"),235+ _ => bail!("choose one of --public, --private or --share"),236+ };237+238+ let config = load_authenticated(paths)?;239+ let api = ControlPlane::new(server, config.session_token.clone());240+241+ let response: CreateResourceResponse = api242+ .post(243+ "/api/resources",244+ &serde_json::json!({245+ "name": name,246+ "kind": "service",247+ "visibility": visibility_str(visibility),248+ // Deliberately absent: the local origin never leaves this machine.249+ "share_with": share.iter().map(|h| h.trim_start_matches('@')).collect::<Vec<_>>(),250+ }),251+ )252+ .await?;253+254+ let resource_id = response255+ .resource_id256+ .parse()257+ .context("the server returned an unusable resource id")?;258+259+ let mut config = paths.load_config()?;260+ config.resources.retain(|r| r.name != name);261+ config.resources.push(ExposedResource {262+ resource_id,263+ name: name.to_string(),264+ origin: origin.clone(),265+ visibility,266+ });267+ paths.save_config(&config)?;268+269+ println!("exposed {name} → {origin} ({})", visibility_str(visibility));270+ if !share.is_empty() {271+ println!(" shared with {}", share.join(", "));272+ }273+ println!(" resource id {resource_id}");274+ println!("\nrun `devsite daemon run` to serve it.");275+ Ok(())276+}277+278+fn visibility_str(visibility: Visibility) -> &'static str {279+ match visibility {280+ Visibility::Public => "public",281+ Visibility::Private => "private",282+ Visibility::Shared => "shared",283+ }284+}285+286+fn status(paths: &Paths) -> Result<()> {287+ let config = paths.load_config()?;288+ println!("config {}", paths.config().display());289+ println!("identity {}", paths.identity().display());290+ println!(291+ "server {}",292+ config.server_url.as_deref().unwrap_or("(not signed in)")293+ );294+ println!(295+ "signing key {}",296+ config.control_plane_key.as_deref().unwrap_or("(not pinned)")297+ );298+299+ if config.resources.is_empty() {300+ println!("\nno services exposed yet");301+ return Ok(());302+ }303+ println!("\nexposed services:");304+ for resource in &config.resources {305+ println!(306+ " {:<16} {:<10} {}",307+ resource.name,308+ visibility_str(resource.visibility),309+ resource.origin310+ );311+ }312+ Ok(())313+}314+315+async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {316+ let config = load_authenticated(paths)?;317+ let server_url = config.server_url.clone().unwrap_or_else(|| server.to_string());318+ let token = config.session_token.clone();319+320+ // Reported on every heartbeat so the control plane can tell "daemon is up" from321+ // "daemon still serves this", and never advertise an exposure that has been removed.322+ let serving: Vec<String> = config323+ .resources324+ .iter()325+ .map(|r| r.resource_id.to_string())326+ .collect();327+328+ let secret_key = paths.load_or_create_identity()?;329+ let daemon = Arc::new(Daemon::bind(secret_key, config).await?);330+331+ let endpoint_id = daemon.endpoint().id().to_string();332+ let relay = daemon333+ .endpoint()334+ .addr()335+ .relay_urls()336+ .next()337+ .map(|u| u.to_string())338+ .context("no relay was assigned; check network connectivity")?;339+340+ println!("daemon online");341+ println!(" endpoint {endpoint_id}");342+ println!(" relay {relay}");343+344+ // Heartbeat loop: presence in the UI, and the address browsers are handed.345+ let api = ControlPlane::new(&server_url, token);346+ let heartbeat = tokio::spawn(async move {347+ let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));348+ loop {349+ ticker.tick().await;350+ if let Err(err) = api351+ .post_empty(352+ "/api/daemon/heartbeat",353+ &serde_json::json!({354+ "endpoint_id": endpoint_id,355+ "relay_url": relay,356+ "serving": serving,357+ }),358+ )359+ .await360+ {361+ tracing::warn!("heartbeat failed: {err:#}");362+ }363+ }364+ });365+366+ tokio::select! {367+ result = Arc::clone(&daemon).serve() => result?,368+ _ = tokio::signal::ctrl_c() => println!("\nshutting down"),369+ }370+ heartbeat.abort();371+ Ok(())372+}
A crates/devsite-client/Cargo.toml +12 -0
A crates/devsite-client/src/lib.rs +193 -0
@@ -0,0 +1,193 @@
1+//! The viewer side of the dev.site data plane.2+//!3+//! This crate compiles for both native and `wasm32-unknown-unknown`. The browser runs it4+//! through `devsite-web`; the integration tests run it natively. Keeping one5+//! implementation means the authorization behaviour proven by fast native tests is6+//! literally the same code the browser executes.7+8+use std::collections::HashMap;9+use std::sync::Mutex;10+11+use anyhow::{Context, Result};12+use devsite_proto::capability::SignedCapability;13+use devsite_proto::wire::{self, ErrorCode, HttpRequest, Method, Request, Response};14+use devsite_proto::ALPN;15+use iroh::endpoint::Connection;16+use iroh::{Endpoint, EndpointAddr, EndpointId, RelayUrl};17+18+/// Why a fetch did not produce a page.19+///20+/// Transport failure and refusal are kept distinct so a caller — and especially a test —21+/// can tell "the daemon said no" from "we never reached the daemon". Collapsing the two22+/// would let a network timeout masquerade as a successful authorization check.23+#[derive(Debug, thiserror::Error)]24+pub enum FetchError {25+ /// The daemon reached a verdict and refused.26+ #[error("the daemon denied this request")]27+ Denied,28+ /// The daemon responded, but with some other refusal.29+ #[error("the daemon rejected this request ({0:?})")]30+ Rejected(ErrorCode),31+ /// We never got a verdict: connection, relay, or protocol failure.32+ #[error(transparent)]33+ Transport(#[from] anyhow::Error),34+}35+36+impl From<wire::CodecError> for FetchError {37+ fn from(err: wire::CodecError) -> Self {38+ FetchError::Transport(err.into())39+ }40+}41+42+/// A page fetched from a remote daemon.43+#[derive(Debug, Clone)]44+pub struct FetchedPage {45+ pub status: u16,46+ pub content_type: Option<String>,47+ pub body: Vec<u8>,48+}49+50+impl FetchedPage {51+ /// The body as text. Used to hand HTML to a sandboxed iframe.52+ pub fn text(&self) -> String {53+ String::from_utf8_lossy(&self.body).into_owned()54+ }55+}56+57+/// A viewer endpoint. In the browser this is ephemeral — a fresh key per tab session — so58+/// a capability issued for it dies with the page.59+pub struct ViewerEndpoint {60+ endpoint: Endpoint,61+ /// One live connection per daemon, reused across requests.62+ ///63+ /// Connecting per request does not work: dropping a `Connection` closes it, and the64+ /// endpoint hands the same closed connection back to the next `connect` for that peer,65+ /// which then fails with "closed". Holding it open is also simply what a browsing66+ /// session wants — several pages from one daemon over one relay connection.67+ connections: Mutex<HashMap<EndpointId, Connection>>,68+}69+70+impl ViewerEndpoint {71+ /// Bind a new endpoint with a freshly generated key and wait until a relay has72+ /// accepted us. Browsers have no direct UDP path, so the relay is the only way out.73+ pub async fn create() -> Result<Self> {74+ let endpoint = Endpoint::builder(iroh::endpoint::presets::N0)75+ .bind()76+ .await77+ .context("binding viewer endpoint")?;78+ endpoint.online().await;79+ Ok(Self {80+ endpoint,81+ connections: Mutex::new(HashMap::new()),82+ })83+ }84+85+ /// This viewer's public key. The control plane binds capabilities to it, and the86+ /// daemon checks that binding against the authenticated peer of the connection.87+ pub fn endpoint_id(&self) -> EndpointId {88+ self.endpoint.id()89+ }90+91+ /// Fetch a page from a daemon over the relay, presenting `capability`.92+ ///93+ /// The capability must have been issued for this endpoint's key; the daemon checks94+ /// that binding against the connection and will refuse otherwise.95+ pub async fn fetch(96+ &self,97+ daemon: EndpointId,98+ relay: RelayUrl,99+ capability: SignedCapability,100+ path: &str,101+ ) -> Result<FetchedPage, FetchError> {102+ let connection = self.connection_to(daemon, &relay).await?;103+ match request_page(&connection, capability.clone(), path).await {104+ Err(FetchError::Transport(err)) => {105+ // The cached connection may simply have aged out. Drop it and try once106+ // more on a fresh one before reporting failure.107+ tracing::debug!("retrying on a fresh connection: {err:#}");108+ self.connections.lock().unwrap().remove(&daemon);109+ let connection = self.connection_to(daemon, &relay).await?;110+ request_page(&connection, capability, path).await111+ }112+ other => other,113+ }114+ }115+116+ /// A live connection to `daemon`, reusing the cached one when there is one.117+ async fn connection_to(118+ &self,119+ daemon: EndpointId,120+ relay: &RelayUrl,121+ ) -> Result<Connection, FetchError> {122+ // Scoped so the guard is released before the await below. An `if let` scrutinee123+ // temporary would otherwise live to the end of the whole statement, and the124+ // second lock in this function would deadlock against it.125+ let existing = {126+ let cache = self.connections.lock().unwrap();127+ cache.get(&daemon).cloned()128+ };129+ if let Some(existing) = existing {130+ return Ok(existing);131+ }132+ let addr = EndpointAddr::from(daemon).with_relay_url(relay.clone());133+ let connection = self134+ .endpoint135+ .connect(addr, ALPN)136+ .await137+ .context("connecting to daemon")?;138+ self.connections139+ .lock()140+ .unwrap()141+ .insert(daemon, connection.clone());142+ Ok(connection)143+ }144+}145+146+async fn request_page(147+ connection: &Connection,148+ capability: SignedCapability,149+ path: &str,150+) -> Result<FetchedPage, FetchError> {151+ let (mut send, mut recv) = connection152+ .open_bi()153+ .await154+ .context("opening bidirectional stream")?;155+156+ let request = Request::Http(HttpRequest {157+ capability,158+ method: Method::Get,159+ path: path.to_string(),160+ });161+ send.write_all(&wire::encode(&request)?)162+ .await163+ .context("writing request frame")?;164+ send.finish().context("finishing request stream")?;165+166+ let mut prefix = [0u8; 4];167+ recv.read_exact(&mut prefix)168+ .await169+ .context("reading response length")?;170+ let len = wire::frame_len(prefix)?;171+ let mut payload = vec![0u8; len];172+ recv.read_exact(&mut payload)173+ .await174+ .context("reading response frame")?;175+176+ let response = wire::decode::<Response>(&payload).context("decoding response frame")?;177+ match response {178+ Response::Http {179+ status,180+ content_type,181+ body,182+ } => Ok(FetchedPage {183+ status,184+ content_type,185+ body,186+ }),187+ Response::Error {188+ code: ErrorCode::Denied,189+ ..190+ } => Err(FetchError::Denied),191+ Response::Error { code, .. } => Err(FetchError::Rejected(code)),192+ }193+}
A crates/devsite-daemon/Cargo.toml +25 -0
A crates/devsite-daemon/src/config.rs +235 -0
@@ -0,0 +1,235 @@
1+//! Daemon state: its identity, the control plane it trusts, and the resources it serves.2+//!3+//! The resource map is the security boundary that keeps the daemon from being an open4+//! proxy. A capability names a *resource id*; only this map turns that into an origin, and5+//! it is written exclusively by the local `devsite` CLI.6+7+use std::collections::HashMap;8+use std::path::{Path, PathBuf};9+10+use anyhow::{bail, Context, Result};11+use devsite_proto::ResourceId;12+use ed25519_dalek::VerifyingKey;13+use iroh::SecretKey;14+use serde::{Deserialize, Serialize};15+use url::Url;16+17+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]18+#[serde(rename_all = "lowercase")]19+pub enum Visibility {20+ Public,21+ Private,22+ Shared,23+}24+25+#[derive(Debug, Clone, Serialize, Deserialize)]26+pub struct ExposedResource {27+ pub resource_id: ResourceId,28+ pub name: String,29+ pub origin: Url,30+ pub visibility: Visibility,31+}32+33+#[derive(Debug, Clone, Default, Serialize, Deserialize)]34+pub struct DaemonConfig {35+ /// Base URL of the control plane, e.g. `https://dev.site`.36+ pub server_url: Option<String>,37+ /// Session token from `devsite login`, used for heartbeats and CLI calls.38+ pub session_token: Option<String>,39+ /// The control plane's capability-signing public key, pinned at login.40+ ///41+ /// Hex-encoded. A daemon that has not pinned a key cannot verify anything and refuses42+ /// every request, which is the correct failure direction.43+ pub control_plane_key: Option<String>,44+ #[serde(default)]45+ pub resources: Vec<ExposedResource>,46+}47+48+impl DaemonConfig {49+ pub fn verifying_key(&self) -> Result<VerifyingKey> {50+ let hex = self51+ .control_plane_key52+ .as_deref()53+ .context("no control plane key pinned — run `devsite login` first")?;54+ let raw = data_encoding::HEXLOWER_PERMISSIVE55+ .decode(hex.as_bytes())56+ .context("control plane key is not valid hex")?;57+ let bytes: [u8; 32] = raw58+ .try_into()59+ .map_err(|_| anyhow::anyhow!("control plane key is not 32 bytes"))?;60+ VerifyingKey::from_bytes(&bytes).context("control plane key is not a valid ed25519 key")61+ }62+63+ /// Resource id to origin. Built fresh on each use so a config reload takes effect.64+ pub fn origins(&self) -> HashMap<ResourceId, Url> {65+ self.resources66+ .iter()67+ .map(|r| (r.resource_id, r.origin.clone()))68+ .collect()69+ }70+}71+72+/// Where the daemon keeps its identity and configuration.73+pub struct Paths {74+ pub root: PathBuf,75+}76+77+impl Paths {78+ pub fn discover() -> Result<Self> {79+ // DEVSITE_HOME keeps integration tests (and multiple daemons on one machine) from80+ // colliding on a single shared identity.81+ if let Ok(custom) = std::env::var("DEVSITE_HOME") {82+ return Ok(Self {83+ root: PathBuf::from(custom),84+ });85+ }86+ let dirs = directories::ProjectDirs::from("dev", "devsite", "devsite")87+ .context("could not determine a config directory")?;88+ Ok(Self {89+ root: dirs.config_dir().to_path_buf(),90+ })91+ }92+93+ pub fn identity(&self) -> PathBuf {94+ self.root.join("identity.key")95+ }96+97+ pub fn config(&self) -> PathBuf {98+ self.root.join("config.json")99+ }100+101+ pub fn load_config(&self) -> Result<DaemonConfig> {102+ let path = self.config();103+ if !path.exists() {104+ return Ok(DaemonConfig::default());105+ }106+ let text = std::fs::read_to_string(&path)107+ .with_context(|| format!("reading {}", path.display()))?;108+ serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))109+ }110+111+ pub fn save_config(&self, config: &DaemonConfig) -> Result<()> {112+ std::fs::create_dir_all(&self.root)?;113+ let text = serde_json::to_string_pretty(config)?;114+ write_private(&self.config(), text.as_bytes())115+ }116+117+ /// Load the persistent endpoint key, creating it on first run.118+ ///119+ /// This key *is* the daemon's identity — every capability ever issued names it as the120+ /// audience — so it must survive restarts and must not be world-readable.121+ pub fn load_or_create_identity(&self) -> Result<SecretKey> {122+ let path = self.identity();123+ if path.exists() {124+ let raw = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;125+ let bytes: [u8; 32] = raw126+ .as_slice()127+ .try_into()128+ .map_err(|_| anyhow::anyhow!("{} is not a 32 byte key", path.display()))?;129+ return Ok(SecretKey::from_bytes(&bytes));130+ }131+132+ std::fs::create_dir_all(&self.root)?;133+ let key = SecretKey::generate();134+ write_private(&path, &key.to_bytes())?;135+ Ok(key)136+ }137+}138+139+/// Write a file only the owner can read.140+///141+/// The mode is set before any bytes land on disk, so the secret is never briefly exposed142+/// with default permissions.143+fn write_private(path: &Path, contents: &[u8]) -> Result<()> {144+ #[cfg(unix)]145+ {146+ use std::io::Write;147+ use std::os::unix::fs::OpenOptionsExt;148+149+ let mut file = std::fs::OpenOptions::new()150+ .write(true)151+ .create(true)152+ .truncate(true)153+ .mode(0o600)154+ .open(path)155+ .with_context(|| format!("opening {}", path.display()))?;156+ file.write_all(contents)?;157+ return Ok(());158+ }159+ #[cfg(not(unix))]160+ {161+ std::fs::write(path, contents)162+ .with_context(|| format!("writing {}", path.display()))?;163+ Ok(())164+ }165+}166+167+/// Reject origins the daemon should never proxy to.168+///169+/// The point of dev.site is reaching services that are *not* publicly routable. Allowing170+/// an arbitrary public origin would turn a daemon into a traffic launderer for its owner's171+/// IP, so exposures are limited to loopback and private/tailnet ranges.172+pub fn validate_origin(origin: &Url) -> Result<()> {173+ match origin.scheme() {174+ "http" | "https" => {}175+ other => bail!("unsupported scheme `{other}` — expected http or https"),176+ }177+ let host = origin.host_str().context("origin has no host")?;178+179+ if host == "localhost" || host.ends_with(".localhost") {180+ return Ok(());181+ }182+ if let Ok(ip) = host.parse::<std::net::IpAddr>() {183+ if ip.is_loopback() || is_private(&ip) {184+ return Ok(());185+ }186+ bail!("{host} is a public address; dev.site only exposes local services");187+ }188+ // Tailnet names resolve to CGNAT space and are the documented remote case.189+ if host.ends_with(".ts.net") {190+ return Ok(());191+ }192+ bail!("{host} is not a local or tailnet address")193+}194+195+fn is_private(ip: &std::net::IpAddr) -> bool {196+ match ip {197+ std::net::IpAddr::V4(v4) => {198+ // 100.64.0.0/10 is CGNAT, which is what Tailscale hands out.199+ v4.is_private() || v4.is_link_local() || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))200+ }201+ std::net::IpAddr::V6(v6) => v6.is_unique_local() || v6.is_unicast_link_local(),202+ }203+}204+205+#[cfg(test)]206+mod tests {207+ use super::*;208+209+ fn url(s: &str) -> Url {210+ Url::parse(s).unwrap()211+ }212+213+ #[test]214+ fn accepts_local_origins() {215+ for good in [216+ "http://127.0.0.1:4101",217+ "http://localhost:8080",218+ "http://192.168.1.10:3000",219+ "http://100.101.102.103:80",220+ "https://hermes.tailbe516a.ts.net/chat",221+ ] {222+ validate_origin(&url(good)).unwrap_or_else(|e| panic!("{good} rejected: {e}"));223+ }224+ }225+226+ #[test]227+ fn refuses_to_expose_the_public_internet() {228+ for bad in ["http://93.184.216.34", "https://example.com", "ftp://127.0.0.1"] {229+ assert!(230+ validate_origin(&url(bad)).is_err(),231+ "{bad} should have been rejected"232+ );233+ }234+ }235+}
A crates/devsite-daemon/src/lib.rs +214 -0
@@ -0,0 +1,214 @@
1+//! The daemon: an Iroh endpoint that proxies *authorized* requests to local services.2+//!3+//! It has no inbound port. It reaches the world through a relay, and it decides for itself4+//! whether to honour a request by verifying a capability signed by the control plane —5+//! the control plane never touches this traffic.6+7+pub mod config;8+pub mod upstream;9+pub mod verify;10+11+use std::sync::Arc;12+use std::time::{SystemTime, UNIX_EPOCH};13+14+use anyhow::{Context, Result};15+use devsite_proto::capability::KeyBytes;16+use devsite_proto::wire::{self, ErrorCode, Request, Response};17+use devsite_proto::ALPN;18+use ed25519_dalek::VerifyingKey;19+use iroh::endpoint::Connection;20+use iroh::{Endpoint, SecretKey};21+use tokio::sync::Mutex;22+23+use crate::config::DaemonConfig;24+use crate::verify::{Denied, ReplayGuard};25+26+pub fn now_secs() -> u64 {27+ SystemTime::now()28+ .duration_since(UNIX_EPOCH)29+ .map(|d| d.as_secs())30+ .unwrap_or(0)31+}32+33+pub struct Daemon {34+ endpoint: Endpoint,35+ http: reqwest::Client,36+ config: DaemonConfig,37+ control_plane_key: VerifyingKey,38+ replay: Arc<Mutex<ReplayGuard>>,39+}40+41+impl Daemon {42+ /// Bind the endpoint and wait until a relay will carry traffic for us.43+ ///44+ /// Fails if no control-plane key has been pinned: a daemon that cannot verify45+ /// capabilities must refuse to start rather than run in some permissive mode.46+ pub async fn bind(secret_key: SecretKey, config: DaemonConfig) -> Result<Self> {47+ let control_plane_key = config.verifying_key()?;48+49+ let endpoint = Endpoint::builder(iroh::endpoint::presets::N0)50+ .secret_key(secret_key)51+ .alpns(vec![ALPN.to_vec()])52+ .bind()53+ .await54+ .context("binding daemon endpoint")?;55+ endpoint.online().await;56+57+ let http = reqwest::Client::builder()58+ .redirect(reqwest::redirect::Policy::none())59+ .build()60+ .context("building upstream http client")?;61+62+ Ok(Self {63+ endpoint,64+ http,65+ config,66+ control_plane_key,67+ replay: Arc::new(Mutex::new(ReplayGuard::default())),68+ })69+ }70+71+ pub fn endpoint(&self) -> &Endpoint {72+ &self.endpoint73+ }74+75+ pub fn endpoint_key(&self) -> KeyBytes {76+ *self.endpoint.id().as_bytes()77+ }78+79+ /// Accept connections until the endpoint closes.80+ pub async fn serve(self: Arc<Self>) -> Result<()> {81+ tracing::info!(82+ resources = self.config.resources.len(),83+ "daemon serving; every request must present a valid capability"84+ );85+86+ while let Some(incoming) = self.endpoint.accept().await {87+ let daemon = Arc::clone(&self);88+ tokio::spawn(async move {89+ match incoming.await {90+ Ok(connection) => daemon.handle_connection(connection).await,91+ Err(err) => tracing::debug!("incoming connection failed: {err:#}"),92+ }93+ });94+ }95+ Ok(())96+ }97+98+ /// Serve requests until the peer goes away.99+ ///100+ /// A connection carries many requests, so this loops rather than answering once and101+ /// dropping. Closing after a single response leaves the peer holding a dead connection102+ /// that its next request reuses — which surfaces as a connect failing with "closed"103+ /// rather than as anything obviously wrong here.104+ async fn handle_connection(self: Arc<Self>, connection: Connection) {105+ // The authenticated peer. This is the only identity we trust about the other side,106+ // and the capability's browser binding is checked against it.107+ let peer: KeyBytes = *connection.remote_id().as_bytes();108+109+ loop {110+ let stream = match connection.accept_bi().await {111+ Ok(stream) => stream,112+ // The peer closed, or the connection dropped. Either way we are done.113+ Err(err) => {114+ tracing::trace!("connection with {} ended: {err}", hex(&peer));115+ return;116+ }117+ };118+119+ let daemon = Arc::clone(&self);120+ tokio::spawn(async move {121+ if let Err(err) = daemon.handle_stream(stream, &peer).await {122+ tracing::debug!("request from {} failed: {err:#}", hex(&peer));123+ }124+ });125+ }126+ }127+128+ async fn handle_stream(129+ &self,130+ (mut send, mut recv): (iroh::endpoint::SendStream, iroh::endpoint::RecvStream),131+ peer: &KeyBytes,132+ ) -> Result<()> {133+ let mut prefix = [0u8; 4];134+ recv.read_exact(&mut prefix)135+ .await136+ .context("reading request length")?;137+ let len = wire::frame_len(prefix)?;138+ let mut payload = vec![0u8; len];139+ recv.read_exact(&mut payload)140+ .await141+ .context("reading request frame")?;142+143+ let response = match wire::decode::<Request>(&payload) {144+ Ok(Request::Http(request)) => self.serve_http(request, peer).await,145+ Err(err) => {146+ tracing::debug!("malformed frame from {}: {err:#}", hex(peer));147+ Response::Error {148+ code: ErrorCode::BadRequest,149+ message: "malformed request".to_string(),150+ }151+ }152+ };153+154+ send.write_all(&wire::encode(&response)?)155+ .await156+ .context("writing response frame")?;157+ send.finish().context("finishing response stream")?;158+ // Wait for the peer to acknowledge, so the response is not discarded if the159+ // connection is torn down immediately after.160+ send.stopped().await.ok();161+ Ok(())162+ }163+164+ async fn serve_http(&self, request: devsite_proto::HttpRequest, peer: &KeyBytes) -> Response {165+ let origins = self.config.origins();166+ let my_endpoint = self.endpoint_key();167+ let now = now_secs();168+169+ let authorized = {170+ let mut replay = self.replay.lock().await;171+ verify::authorize(172+ &request.capability,173+ request.method,174+ &self.control_plane_key,175+ &my_endpoint,176+ peer,177+ &origins,178+ &mut replay,179+ now,180+ )181+ };182+183+ match authorized {184+ Ok(authorized) => {185+ tracing::info!(186+ viewer = %authorized.claims.viewer,187+ resource = %authorized.claims.resource,188+ path = %request.path,189+ "serving request"190+ );191+ upstream::fetch(&self.http, &authorized.origin, request.method, &request.path).await192+ }193+ Err(denied) => {194+ // Logged with detail locally, reported to the peer as a bare "denied".195+ tracing::warn!(peer = %hex(peer), reason = ?denied, "denied request");196+ debug_assert!(matches!(197+ denied,198+ Denied::Capability199+ | Denied::UnknownResource200+ | Denied::MethodNotPermitted201+ | Denied::Replayed202+ ));203+ Response::Error {204+ code: ErrorCode::Denied,205+ message: "denied".to_string(),206+ }207+ }208+ }209+ }210+}211+212+fn hex(bytes: &KeyBytes) -> String {213+ data_encoding::HEXLOWER.encode(bytes)214+}
A crates/devsite-daemon/src/upstream.rs +162 -0
@@ -0,0 +1,162 @@
1+//! The loopback HTTP hop, and the path validation that guards it.2+3+use anyhow::{Context, Result};4+use devsite_proto::wire::{ErrorCode, Method, Response};5+use url::Url;6+7+/// Ceiling on an upstream response body. Keeps a large local service from blowing up the8+/// daemon or the frame limit.9+pub const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;10+11+const UPSTREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);12+13+/// Reject anything that could redirect the request away from the configured origin.14+///15+/// The peer supplies only a path. Without this check a peer could send an absolute URL16+/// (`http://evil.example/`) or traverse with `..` and turn the daemon into an open proxy —17+/// exactly the property the whole design exists to prevent.18+pub fn validate_path(path: &str) -> Result<&str, ErrorCode> {19+ if !path.starts_with('/') {20+ return Err(ErrorCode::BadRequest);21+ }22+ // `//host/x` is a protocol-relative URL and would re-target the request when joined.23+ if path.starts_with("//") {24+ return Err(ErrorCode::BadRequest);25+ }26+ if path.contains("..") {27+ return Err(ErrorCode::BadRequest);28+ }29+ if path.contains(['\\', '\r', '\n', '\0']) {30+ return Err(ErrorCode::BadRequest);31+ }32+ Ok(path)33+}34+35+/// Build the upstream URL from the *configured* origin plus a validated path.36+///37+/// `Url::join` would happily replace the whole origin if given an absolute URL, so38+/// `validate_path` must run first — it does, on the line below, and the result is used39+/// rather than the raw input.40+pub fn upstream_url(origin: &Url, path: &str) -> Result<Url, ErrorCode> {41+ let path = validate_path(path)?;42+ origin.join(path).map_err(|_| ErrorCode::BadRequest)43+}44+45+/// Perform the loopback request and turn it into a response frame.46+pub async fn fetch(47+ http: &reqwest::Client,48+ origin: &Url,49+ method: Method,50+ path: &str,51+) -> Response {52+ let url = match upstream_url(origin, path) {53+ Ok(url) => url,54+ Err(code) => {55+ return Response::Error {56+ code,57+ message: "invalid path".to_string(),58+ }59+ }60+ };61+62+ match perform(http, method, url).await {63+ Ok(response) => response,64+ Err(err) => {65+ tracing::warn!("upstream request failed: {err:#}");66+ Response::Error {67+ code: ErrorCode::UpstreamUnavailable,68+ message: "local service did not respond".to_string(),69+ }70+ }71+ }72+}73+74+async fn perform(http: &reqwest::Client, method: Method, url: Url) -> Result<Response> {75+ let request = match method {76+ Method::Get => http.get(url.clone()),77+ Method::Head => http.head(url.clone()),78+ };79+ let response = request80+ .timeout(UPSTREAM_TIMEOUT)81+ .send()82+ .await83+ .with_context(|| format!("requesting {url}"))?;84+85+ let status = response.status().as_u16();86+ let content_type = response87+ .headers()88+ .get(reqwest::header::CONTENT_TYPE)89+ .and_then(|value| value.to_str().ok())90+ .map(str::to_string);91+92+ // Check the advertised length first so an oversized body is refused before it is93+ // buffered, then re-check after reading since the header is only a hint.94+ if response.content_length().is_some_and(|n| n as usize > MAX_BODY_BYTES) {95+ return Ok(Response::Error {96+ code: ErrorCode::UpstreamTooLarge,97+ message: "local service response is too large".to_string(),98+ });99+ }100+101+ let body = response.bytes().await.context("reading upstream body")?;102+ if body.len() > MAX_BODY_BYTES {103+ return Ok(Response::Error {104+ code: ErrorCode::UpstreamTooLarge,105+ message: "local service response is too large".to_string(),106+ });107+ }108+109+ Ok(Response::Http {110+ status,111+ content_type,112+ body: body.to_vec(),113+ })114+}115+116+#[cfg(test)]117+mod tests {118+ use super::*;119+120+ fn origin() -> Url {121+ Url::parse("http://127.0.0.1:4101").unwrap()122+ }123+124+ #[test]125+ fn accepts_ordinary_paths() {126+ assert!(upstream_url(&origin(), "/").is_ok());127+ assert_eq!(128+ upstream_url(&origin(), "/chat").unwrap().as_str(),129+ "http://127.0.0.1:4101/chat"130+ );131+ }132+133+ #[test]134+ fn refuses_to_become_an_open_proxy() {135+ // Each of these, if joined naively, retargets the request at another host.136+ for hostile in [137+ "http://evil.example/",138+ "https://evil.example/",139+ "//evil.example/",140+ "\\\\evil.example\\",141+ ] {142+ let result = upstream_url(&origin(), hostile);143+ assert!(result.is_err(), "{hostile} should have been rejected");144+ }145+ }146+147+ #[test]148+ fn refuses_traversal_and_control_characters() {149+ for hostile in ["/../etc/passwd", "/a/../../b", "/x\r\nHost: evil", "/x\0"] {150+ assert!(151+ upstream_url(&origin(), hostile).is_err(),152+ "{hostile} should have been rejected"153+ );154+ }155+ }156+157+ #[test]158+ fn requires_an_absolute_path() {159+ assert!(upstream_url(&origin(), "chat").is_err());160+ assert!(upstream_url(&origin(), "").is_err());161+ }162+}
A crates/devsite-daemon/src/verify.rs +222 -0
@@ -0,0 +1,222 @@
1+//! Deciding whether to honour a request.2+//!3+//! Everything here is deliberately paranoid and deliberately quiet: the caller learns only4+//! that it was denied, never *why*. A peer that could distinguish "no such resource" from5+//! "not shared with you" could enumerate a profile's private services.6+7+use std::collections::HashMap;8+9+use devsite_proto::capability::{CapabilityClaims, KeyBytes, Permission, SignedCapability};10+use devsite_proto::wire::Method;11+use devsite_proto::ResourceId;12+use ed25519_dalek::VerifyingKey;13+use url::Url;14+15+/// A request that passed every check, carrying the origin it resolved to.16+#[derive(Debug)]17+pub struct Authorized {18+ pub claims: CapabilityClaims,19+ pub origin: Url,20+}21+22+#[derive(Debug, Clone, Copy, PartialEq, Eq)]23+pub enum Denied {24+ /// Signature, audience, binding, or expiry failed.25+ Capability,26+ /// Well-formed and correctly signed, but names a resource this daemon does not serve.27+ UnknownResource,28+ /// The grant does not cover this method.29+ MethodNotPermitted,30+ /// This exact capability has already been used.31+ Replayed,32+}33+34+/// Remembers recently used capability nonces so a grant cannot be redeemed twice.35+///36+/// Bounded by expiry rather than by count: entries are pruned once the capability they37+/// describe could no longer be valid anyway, so memory tracks issuance rate, not uptime.38+#[derive(Default)]39+pub struct ReplayGuard {40+ seen: HashMap<[u8; 16], u64>,41+}42+43+impl ReplayGuard {44+ /// Record a nonce. Returns false if it had already been used.45+ pub fn admit(&mut self, nonce: [u8; 16], expires_at: u64, now: u64) -> bool {46+ self.seen.retain(|_, expiry| *expiry > now);47+ if self.seen.contains_key(&nonce) {48+ return false;49+ }50+ self.seen.insert(nonce, expires_at);51+ true52+ }53+54+ pub fn len(&self) -> usize {55+ self.seen.len()56+ }57+58+ pub fn is_empty(&self) -> bool {59+ self.seen.is_empty()60+ }61+}62+63+/// The full check, in the order that leaks the least.64+///65+/// `peer` must come from the connection's authenticated identity — iroh's66+/// `Connection::remote_id()` — and never from anything inside the request frame.67+pub fn authorize(68+ capability: &SignedCapability,69+ method: Method,70+ control_plane_key: &VerifyingKey,71+ my_endpoint: &KeyBytes,72+ peer: &KeyBytes,73+ origins: &HashMap<ResourceId, Url>,74+ replay: &mut ReplayGuard,75+ now: u64,76+) -> Result<Authorized, Denied> {77+ // Cryptography first: signature, audience, browser binding, expiry. Until this passes78+ // the claims are just bytes a stranger sent us.79+ let claims = capability80+ .verify_for(control_plane_key, my_endpoint, peer, now)81+ .map_err(|_| Denied::Capability)?;82+83+ match claims.permission {84+ Permission::HttpRead => {85+ if !matches!(method, Method::Get | Method::Head) {86+ return Err(Denied::MethodNotPermitted);87+ }88+ }89+ }90+91+ let origin = origins92+ .get(&claims.resource)93+ .ok_or(Denied::UnknownResource)?94+ .clone();95+96+ // Last, because consuming the nonce has a side effect: a request that would have been97+ // refused anyway must not burn a legitimate capability.98+ if !replay.admit(claims.nonce, claims.expires_at, now) {99+ return Err(Denied::Replayed);100+ }101+102+ Ok(Authorized { claims, origin })103+}104+105+#[cfg(test)]106+mod tests {107+ use devsite_proto::capability::DEFAULT_LIFETIME_SECS;108+ use devsite_proto::{AccountId, ResourceId};109+ use ed25519_dalek::SigningKey;110+111+ use super::*;112+113+ const NOW: u64 = 1_760_000_000;114+ const DAEMON: KeyBytes = [7; 32];115+ const BROWSER: KeyBytes = [8; 32];116+117+ fn resource() -> ResourceId {118+ ResourceId::from_bytes([2; 16])119+ }120+121+ fn origins() -> HashMap<ResourceId, Url> {122+ HashMap::from([(resource(), Url::parse("http://127.0.0.1:4101").unwrap())])123+ }124+125+ fn claims_with(nonce: [u8; 16], res: ResourceId) -> CapabilityClaims {126+ CapabilityClaims {127+ issuer: "https://dev.site".to_string(),128+ viewer: AccountId::from_bytes([1; 16]),129+ resource: res,130+ audience: DAEMON,131+ browser_key: BROWSER,132+ permission: Permission::HttpRead,133+ issued_at: NOW,134+ expires_at: NOW + DEFAULT_LIFETIME_SECS,135+ nonce,136+ }137+ }138+139+ fn run(140+ key: &SigningKey,141+ claims: &CapabilityClaims,142+ peer: &KeyBytes,143+ replay: &mut ReplayGuard,144+ ) -> Result<Authorized, Denied> {145+ let cap = SignedCapability::sign(claims, key).unwrap();146+ authorize(147+ &cap,148+ Method::Get,149+ &key.verifying_key(),150+ &DAEMON,151+ peer,152+ &origins(),153+ replay,154+ NOW,155+ )156+ }157+158+ #[test]159+ fn allows_a_valid_request() {160+ let key = SigningKey::from_bytes(&[9; 32]);161+ let mut replay = ReplayGuard::default();162+ let ok = run(&key, &claims_with([1; 16], resource()), &BROWSER, &mut replay).unwrap();163+ assert_eq!(ok.origin.as_str(), "http://127.0.0.1:4101/");164+ }165+166+ #[test]167+ fn denies_a_capability_bound_to_another_browser() {168+ let key = SigningKey::from_bytes(&[9; 32]);169+ let mut replay = ReplayGuard::default();170+ let denied = run(&key, &claims_with([1; 16], resource()), &[99; 32], &mut replay)171+ .unwrap_err();172+ assert_eq!(denied, Denied::Capability);173+ }174+175+ #[test]176+ fn denies_a_resource_this_daemon_does_not_serve() {177+ // Correctly signed and correctly bound, but for a resource id that is not in the178+ // local map — e.g. a capability meant for a service the owner has since removed.179+ let key = SigningKey::from_bytes(&[9; 32]);180+ let mut replay = ReplayGuard::default();181+ let stranger = ResourceId::from_bytes([44; 16]);182+ let denied =183+ run(&key, &claims_with([1; 16], stranger), &BROWSER, &mut replay).unwrap_err();184+ assert_eq!(denied, Denied::UnknownResource);185+ }186+187+ #[test]188+ fn denies_a_second_use_of_the_same_capability() {189+ let key = SigningKey::from_bytes(&[9; 32]);190+ let mut replay = ReplayGuard::default();191+ let claims = claims_with([1; 16], resource());192+ assert!(run(&key, &claims, &BROWSER, &mut replay).is_ok());193+ assert_eq!(194+ run(&key, &claims, &BROWSER, &mut replay).unwrap_err(),195+ Denied::Replayed196+ );197+ }198+199+ #[test]200+ fn a_denied_request_does_not_consume_the_nonce() {201+ // If a wrong-peer attempt burned the nonce, an attacker who merely observed a202+ // capability could deny the legitimate viewer their one use of it.203+ let key = SigningKey::from_bytes(&[9; 32]);204+ let mut replay = ReplayGuard::default();205+ let claims = claims_with([1; 16], resource());206+207+ assert!(run(&key, &claims, &[99; 32], &mut replay).is_err());208+ assert!(replay.is_empty(), "a failed attempt must not record a nonce");209+ assert!(run(&key, &claims, &BROWSER, &mut replay).is_ok());210+ }211+212+ #[test]213+ fn replay_guard_forgets_expired_nonces() {214+ let mut replay = ReplayGuard::default();215+ assert!(replay.admit([1; 16], NOW + 60, NOW));216+ assert!(!replay.admit([1; 16], NOW + 60, NOW));217+ // Once every recorded nonce has expired the table drains rather than growing218+ // without bound.219+ assert!(replay.admit([2; 16], NOW + 3600, NOW + 61));220+ assert_eq!(replay.len(), 1);221+ }222+}
A crates/devsite-daemon/tests/authz.rs +349 -0
@@ -0,0 +1,349 @@
1+//! The authorization matrix, exercised over real Iroh against a real local service.2+//!3+//! These run the same `devsite-client` code the browser runs, so what passes here is what4+//! the browser does. Capabilities are minted directly by a test issuer rather than by the5+//! control plane, which keeps the tests focused on what the *daemon* enforces — the6+//! control plane's own "may this viewer have a capability at all" rules are covered by the7+//! policy tests in devsite-server.8+//!9+//! Negative cases assert `FetchError::Denied` specifically, never merely "an error". A10+//! relay timeout is also an error, and a test that accepted one would report a working11+//! authorization check on a daemon that was simply unreachable.12+//!13+//! Endpoints are shared across cases: reaching a relay is slow, and the public relays14+//! rate-limit, so binding one endpoint per assertion makes the suite flaky rather than15+//! thorough.16+17+use std::sync::Arc;18+19+use devsite_client::{FetchError, ViewerEndpoint};20+use devsite_daemon::config::{DaemonConfig, ExposedResource, Visibility};21+use devsite_daemon::Daemon;22+use devsite_proto::capability::{23+ CapabilityClaims, KeyBytes, Permission, SignedCapability, DEFAULT_LIFETIME_SECS,24+};25+use devsite_proto::{AccountId, ResourceId};26+use ed25519_dalek::SigningKey;27+use iroh::{RelayUrl, SecretKey};28+use tokio::io::{AsyncReadExt, AsyncWriteExt};29+use tokio::net::TcpListener;30+use url::Url;31+32+const HERMES_BODY: &str = "<h1>Hermes</h1>";33+const AGENT_BODY: &str = "<h1>Agent</h1>";34+35+async fn spawn_service(body: &'static str) -> Url {36+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();37+ let port = listener.local_addr().unwrap().port();38+ tokio::spawn(async move {39+ loop {40+ let Ok((mut stream, _)) = listener.accept().await else {41+ return;42+ };43+ tokio::spawn(async move {44+ let mut buf = [0u8; 2048];45+ let _ = stream.read(&mut buf).await;46+ let response = format!(47+ "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",48+ body.len()49+ );50+ let _ = stream.write_all(response.as_bytes()).await;51+ });52+ }53+ });54+ Url::parse(&format!("http://127.0.0.1:{port}")).unwrap()55+}56+57+struct Harness {58+ daemon: Arc<Daemon>,59+ daemon_key: KeyBytes,60+ relay: RelayUrl,61+ signing_key: SigningKey,62+ hermes: ResourceId,63+ agent: ResourceId,64+ unknown: ResourceId,65+ /// The legitimate viewer.66+ viewer: ViewerEndpoint,67+ /// A second viewer, for anything about one browser using another's grant.68+ mallory: ViewerEndpoint,69+}70+71+impl Harness {72+ async fn start() -> Self {73+ let signing_key = SigningKey::from_bytes(&[42; 32]);74+ let hermes = ResourceId::generate();75+ let agent = ResourceId::generate();76+77+ let config = DaemonConfig {78+ server_url: Some("https://dev.site".into()),79+ session_token: Some("test".into()),80+ control_plane_key: Some(81+ data_encoding::HEXLOWER.encode(signing_key.verifying_key().as_bytes()),82+ ),83+ resources: vec![84+ ExposedResource {85+ resource_id: hermes,86+ name: "Hermes".into(),87+ origin: spawn_service(HERMES_BODY).await,88+ visibility: Visibility::Private,89+ },90+ ExposedResource {91+ resource_id: agent,92+ name: "Agent".into(),93+ origin: spawn_service(AGENT_BODY).await,94+ visibility: Visibility::Shared,95+ },96+ ],97+ };98+99+ let daemon = Arc::new(Daemon::bind(SecretKey::generate(), config).await.unwrap());100+ let daemon_key = daemon.endpoint_key();101+ let relay = daemon102+ .endpoint()103+ .addr()104+ .relay_urls()105+ .next()106+ .expect("daemon should have a relay")107+ .clone();108+109+ tokio::spawn(Arc::clone(&daemon).serve());110+111+ let (viewer, mallory) = tokio::join!(ViewerEndpoint::create(), ViewerEndpoint::create());112+113+ Self {114+ daemon,115+ daemon_key,116+ relay,117+ signing_key,118+ hermes,119+ agent,120+ unknown: ResourceId::generate(),121+ viewer: viewer.unwrap(),122+ mallory: mallory.unwrap(),123+ }124+ }125+126+ fn now(&self) -> u64 {127+ devsite_daemon::now_secs()128+ }129+130+ /// Mint a capability with every field under the test's control.131+ fn mint(132+ &self,133+ key: &SigningKey,134+ resource: ResourceId,135+ audience: KeyBytes,136+ browser_key: KeyBytes,137+ issued_at: u64,138+ expires_at: u64,139+ ) -> SignedCapability {140+ let mut nonce = [0u8; 16];141+ getrandom::fill(&mut nonce).unwrap();142+ SignedCapability::sign(143+ &CapabilityClaims {144+ issuer: "https://dev.site".into(),145+ viewer: AccountId::generate(),146+ resource,147+ audience,148+ browser_key,149+ permission: Permission::HttpRead,150+ issued_at,151+ expires_at,152+ nonce,153+ },154+ key,155+ )156+ .unwrap()157+ }158+159+ /// An ordinary, valid capability for `resource`, bound to the legitimate viewer.160+ fn valid(&self, resource: ResourceId) -> SignedCapability {161+ let now = self.now();162+ self.mint(163+ &self.signing_key,164+ resource,165+ self.daemon_key,166+ *self.viewer.endpoint_id().as_bytes(),167+ now,168+ now + DEFAULT_LIFETIME_SECS,169+ )170+ }171+172+ async fn fetch_as(173+ &self,174+ who: &ViewerEndpoint,175+ capability: SignedCapability,176+ path: &str,177+ ) -> Result<String, FetchError> {178+ who.fetch(179+ self.daemon.endpoint().id(),180+ self.relay.clone(),181+ capability,182+ path,183+ )184+ .await185+ .map(|page| page.text())186+ }187+188+ async fn fetch(&self, capability: SignedCapability) -> Result<String, FetchError> {189+ self.fetch_as(&self.viewer, capability, "/").await190+ }191+192+ /// Assert the daemon reached a verdict and refused, rather than being unreachable.193+ async fn assert_denied(&self, capability: SignedCapability, case: &str) {194+ match self.fetch(capability).await {195+ Err(FetchError::Denied) => {}196+ Err(FetchError::Transport(err)) => {197+ panic!("{case}: never reached a verdict — {err:#}")198+ }199+ Err(other) => panic!("{case}: expected a denial, got {other}"),200+ Ok(body) => panic!("{case}: expected a denial, but the page was served: {body}"),201+ }202+ }203+}204+205+/// The whole matrix runs in one test, on one runtime.206+///207+/// Separate `#[tokio::test]` functions each get their own runtime, and the daemon's accept208+/// loop lives on whichever one built the shared harness first. Once that runtime shuts209+/// down the daemon stops answering and every later case hangs — so the cases are sequenced210+/// here instead, which is also faster and easier on the public relays.211+#[tokio::test]212+async fn authorization_matrix() {213+ let h = Harness::start().await;214+215+ valid_capability_fetches_the_page(&h).await;216+ each_resource_maps_to_its_own_service(&h).await;217+ capability_bound_to_another_browser_is_refused(&h).await;218+ capabilities_that_fail_verification_are_refused(&h).await;219+ a_capability_cannot_be_redeemed_twice(&h).await;220+ denials_reveal_nothing_about_why(&h).await;221+ the_daemon_cannot_be_turned_into_an_open_proxy(&h).await;222+}223+224+async fn valid_capability_fetches_the_page(h: &Harness) {225+ let page = h226+ .fetch(h.valid(h.hermes))227+ .await228+ .expect("a valid capability should be honoured");229+ assert!(page.contains(HERMES_BODY), "got: {page}");230+}231+232+async fn each_resource_maps_to_its_own_service(h: &Harness) {233+ // Proves the daemon routes by resource id rather than serving one default origin —234+ // otherwise "Frank can reach Agent" would quietly also mean "Frank can reach Hermes".235+ let page = h.fetch(h.valid(h.agent)).await.unwrap();236+ assert!(page.contains(AGENT_BODY), "got: {page}");237+ assert!(!page.contains(HERMES_BODY));238+}239+240+async fn capability_bound_to_another_browser_is_refused(h: &Harness) {241+ // The core claim: a leaked or forwarded capability is useless without the private key242+ // it was bound to. Mallory presents the viewer's capability from her own endpoint.243+ let stolen = h.valid(h.agent);244+ match h.fetch_as(&h.mallory, stolen, "/").await {245+ Err(FetchError::Denied) => {}246+ Err(FetchError::Transport(err)) => panic!("never reached a verdict — {err:#}"),247+ Err(other) => panic!("expected a denial, got {other}"),248+ Ok(body) => panic!("a rebound capability was honoured: {body}"),249+ }250+}251+252+async fn capabilities_that_fail_verification_are_refused(h: &Harness) {253+ let now = h.now();254+ let mine = *h.viewer.endpoint_id().as_bytes();255+256+ // Signed by someone who is not the control plane.257+ h.assert_denied(258+ h.mint(259+ &SigningKey::from_bytes(&[7; 32]),260+ h.hermes,261+ h.daemon_key,262+ mine,263+ now,264+ now + DEFAULT_LIFETIME_SECS,265+ ),266+ "forged signature",267+ )268+ .await;269+270+ // Expired.271+ h.assert_denied(272+ h.mint(&h.signing_key, h.hermes, h.daemon_key, mine, now - 600, now - 300),273+ "expired",274+ )275+ .await;276+277+ // Addressed to a different daemon.278+ h.assert_denied(279+ h.mint(&h.signing_key, h.hermes, [99; 32], mine, now, now + DEFAULT_LIFETIME_SECS),280+ "wrong audience",281+ )282+ .await;283+284+ // Correctly signed and bound, but names a resource this daemon does not serve.285+ h.assert_denied(h.valid(h.unknown), "unknown resource").await;286+}287+288+async fn a_capability_cannot_be_redeemed_twice(h: &Harness) {289+ let capability = h.valid(h.hermes);290+ assert!(h.fetch(capability.clone()).await.is_ok());291+ h.assert_denied(capability, "replayed capability").await;292+}293+294+async fn denials_reveal_nothing_about_why(h: &Harness) {295+ // A peer must not be able to tell "no such resource" from "not signed correctly" —296+ // that difference is enough to enumerate a profile's private services.297+ let now = h.now();298+ let mine = *h.viewer.endpoint_id().as_bytes();299+300+ let unknown = h.fetch(h.valid(h.unknown)).await.unwrap_err();301+ let forged = h302+ .fetch(h.mint(303+ &SigningKey::from_bytes(&[7; 32]),304+ h.hermes,305+ h.daemon_key,306+ mine,307+ now,308+ now + DEFAULT_LIFETIME_SECS,309+ ))310+ .await311+ .unwrap_err();312+313+ assert!(matches!(unknown, FetchError::Denied));314+ assert!(matches!(forged, FetchError::Denied));315+ assert_eq!(316+ unknown.to_string(),317+ forged.to_string(),318+ "denial messages must be indistinguishable"319+ );320+}321+322+async fn the_daemon_cannot_be_turned_into_an_open_proxy(h: &Harness) {323+ // The peer only ever supplies a path. These are the paths that would escape the324+ // configured origin if they were joined onto it naively.325+ for hostile in [326+ "http://example.com/",327+ "//example.com/",328+ "/../../etc/passwd",329+ "/x\r\nHost: example.com",330+ ] {331+ match h.fetch_as(&h.viewer, h.valid(h.hermes), hostile).await {332+ // A bad path is a client error, distinct from an authorization denial.333+ Err(FetchError::Rejected(_)) => {}334+ Err(FetchError::Transport(err)) => {335+ panic!("{hostile:?}: never reached a verdict — {err:#}")336+ }337+ Err(other) => panic!("{hostile:?}: unexpected {other}"),338+ Ok(body) => panic!("{hostile:?} was proxied: {body}"),339+ }340+ }341+}342+343+#[test]344+fn resource_ids_do_not_collide() {345+ let mut seen = std::collections::HashSet::new();346+ for _ in 0..10_000 {347+ assert!(seen.insert(ResourceId::generate()), "generated a duplicate id");348+ }349+}
A crates/devsite-fixture/Cargo.toml +9 -0
A crates/devsite-fixture/src/main.rs +61 -0
@@ -0,0 +1,61 @@
1+//! Stand-in for a real local developer service.2+//!3+//! Serves one self-contained HTML file on a loopback port, for any path. This is what4+//! `127.0.0.1:4101` (Hermes) and `127.0.0.1:4102` (Agent) are during the vertical slice.5+6+use std::path::PathBuf;7+8+use anyhow::{Context, Result};9+use tokio::io::{AsyncReadExt, AsyncWriteExt};10+use tokio::net::{TcpListener, TcpStream};11+12+#[tokio::main]13+async fn main() -> Result<()> {14+ let mut args = std::env::args().skip(1);15+ let port: u16 = args16+ .next()17+ .context("usage: devsite-fixture <port> <file.html>")?18+ .parse()19+ .context("port must be a number")?;20+ let path = PathBuf::from(21+ args.next()22+ .context("usage: devsite-fixture <port> <file.html>")?,23+ );24+25+ let body = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;26+ let listener = TcpListener::bind(("127.0.0.1", port)).await?;27+ println!(28+ "devsite-fixture: serving {} on http://127.0.0.1:{port}",29+ path.display()30+ );31+32+ loop {33+ let (stream, _) = listener.accept().await?;34+ let body = body.clone();35+ tokio::spawn(async move {36+ if let Err(err) = serve(stream, &body).await {37+ eprintln!("devsite-fixture: connection error: {err:#}");38+ }39+ });40+ }41+}42+43+async fn serve(mut stream: TcpStream, body: &[u8]) -> Result<()> {44+ // Read just enough to consume the request line and headers; we serve the same file45+ // regardless of path, so the request contents don't matter.46+ let mut buf = [0u8; 4096];47+ let _ = stream.read(&mut buf).await?;48+49+ let header = format!(50+ "HTTP/1.1 200 OK\r\n\51+ Content-Type: text/html; charset=utf-8\r\n\52+ Content-Length: {}\r\n\53+ Cache-Control: no-store\r\n\54+ Connection: close\r\n\r\n",55+ body.len()56+ );57+ stream.write_all(header.as_bytes()).await?;58+ stream.write_all(body).await?;59+ stream.flush().await?;60+ Ok(())61+}
A crates/devsite-proto/Cargo.toml +13 -0
A crates/devsite-proto/src/capability.rs +263 -0
@@ -0,0 +1,263 @@
1+//! Short-lived, signed grants issued by the control plane and verified by a daemon.2+//!3+//! A capability is the only thing that persuades a daemon to talk to a local service. It4+//! is deliberately narrow: one viewer, one resource, one daemon, one browser key, one5+//! permission, a few minutes.6+//!7+//! Public keys are carried as raw bytes rather than iroh types so this crate stays free of8+//! iroh (and therefore trivially portable). The daemon converts its `EndpointId` with9+//! `as_bytes()` when comparing.10+11+use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};12+use serde::{Deserialize, Serialize};13+14+use crate::{AccountId, ResourceId};15+16+/// Raw Ed25519/iroh public key bytes.17+pub type KeyBytes = [u8; 32];18+19+/// How long a freshly issued capability remains valid. Short enough that a leaked token is20+/// of little use, long enough to survive a slow relay connection.21+pub const DEFAULT_LIFETIME_SECS: u64 = 180;22+23+/// Tolerance for clock skew between the control plane and a daemon.24+const MAX_CLOCK_SKEW_SECS: u64 = 30;25+26+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]27+pub enum Permission {28+ /// GET/HEAD only. The daemon refuses anything that could mutate the local service.29+ HttpRead,30+}31+32+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]33+pub struct CapabilityClaims {34+ pub issuer: String,35+ /// Who is being granted access, by immutable account id — never by handle.36+ pub viewer: AccountId,37+ pub resource: ResourceId,38+ /// The daemon this grant is addressed to. A daemon rejects capabilities minted for a39+ /// different audience, so one daemon cannot replay a grant against another.40+ pub audience: KeyBytes,41+ /// The browser endpoint key this grant is bound to. The daemon checks it against the42+ /// authenticated peer of the connection, which is what makes a stolen capability43+ /// useless without the matching private key.44+ pub browser_key: KeyBytes,45+ pub permission: Permission,46+ pub issued_at: u64,47+ pub expires_at: u64,48+ /// Unique per issuance, so a daemon can refuse to serve the same grant twice.49+ pub nonce: [u8; 16],50+}51+52+/// A capability plus its signature.53+///54+/// `claims` holds the exact bytes that were signed. Verification checks the signature55+/// against *these* bytes and only then deserializes them — re-encoding before verifying56+/// would make the result depend on serializer stability.57+#[derive(Debug, Clone, Serialize, Deserialize)]58+pub struct SignedCapability {59+ claims: Vec<u8>,60+ /// 64 raw signature bytes. Held as a `Vec` because serde has no impl for `[u8; 64]`;61+ /// the length is checked during verification rather than trusted.62+ signature: Vec<u8>,63+}64+65+#[derive(Debug, thiserror::Error, PartialEq, Eq)]66+pub enum CapabilityError {67+ #[error("signature does not verify")]68+ BadSignature,69+ #[error("capability is malformed")]70+ Malformed,71+ #[error("capability has expired")]72+ Expired,73+ #[error("capability is not valid yet")]74+ NotYetValid,75+ #[error("capability was issued for a different daemon")]76+ WrongAudience,77+ #[error("capability is bound to a different browser key")]78+ WrongBrowserKey,79+}80+81+impl SignedCapability {82+ pub fn sign(claims: &CapabilityClaims, key: &SigningKey) -> Result<Self, CapabilityError> {83+ let bytes = postcard::to_allocvec(claims).map_err(|_| CapabilityError::Malformed)?;84+ let signature = key.sign(&bytes);85+ Ok(Self {86+ claims: bytes,87+ signature: signature.to_bytes().to_vec(),88+ })89+ }90+91+ /// Verify the signature and return the claims.92+ ///93+ /// This checks only what the signature attests to. Context-dependent checks — audience,94+ /// expiry, browser binding — live in [`Self::verify_for`], which callers should prefer.95+ pub fn verify(&self, key: &VerifyingKey) -> Result<CapabilityClaims, CapabilityError> {96+ let signature: [u8; 64] = self97+ .signature98+ .as_slice()99+ .try_into()100+ .map_err(|_| CapabilityError::Malformed)?;101+ key.verify(&self.claims, &Signature::from_bytes(&signature))102+ .map_err(|_| CapabilityError::BadSignature)?;103+ postcard::from_bytes(&self.claims).map_err(|_| CapabilityError::Malformed)104+ }105+106+ /// Full verification as a daemon must perform it.107+ ///108+ /// `peer` is the authenticated remote endpoint of the connection the capability109+ /// arrived on — not anything the peer told us about itself.110+ pub fn verify_for(111+ &self,112+ key: &VerifyingKey,113+ audience: &KeyBytes,114+ peer: &KeyBytes,115+ now: u64,116+ ) -> Result<CapabilityClaims, CapabilityError> {117+ let claims = self.verify(key)?;118+119+ if &claims.audience != audience {120+ return Err(CapabilityError::WrongAudience);121+ }122+ // The check the whole design rests on: the capability names a browser key, and the123+ // connection proves possession of one. They must be the same key.124+ if &claims.browser_key != peer {125+ return Err(CapabilityError::WrongBrowserKey);126+ }127+ if now > claims.expires_at {128+ return Err(CapabilityError::Expired);129+ }130+ if claims.issued_at > now.saturating_add(MAX_CLOCK_SKEW_SECS) {131+ return Err(CapabilityError::NotYetValid);132+ }133+ Ok(claims)134+ }135+136+ pub fn to_bytes(&self) -> Vec<u8> {137+ postcard::to_allocvec(self).expect("a signed capability always serializes")138+ }139+140+ pub fn from_bytes(bytes: &[u8]) -> Result<Self, CapabilityError> {141+ postcard::from_bytes(bytes).map_err(|_| CapabilityError::Malformed)142+ }143+}144+145+#[cfg(test)]146+mod tests {147+ use super::*;148+149+ const NOW: u64 = 1_760_000_000;150+151+ fn signing_key(seed: u8) -> SigningKey {152+ SigningKey::from_bytes(&[seed; 32])153+ }154+155+ fn claims(audience: KeyBytes, browser_key: KeyBytes) -> CapabilityClaims {156+ CapabilityClaims {157+ issuer: "https://dev.site".to_string(),158+ viewer: AccountId::from_bytes([1; 16]),159+ resource: ResourceId::from_bytes([2; 16]),160+ audience,161+ browser_key,162+ permission: Permission::HttpRead,163+ issued_at: NOW,164+ expires_at: NOW + DEFAULT_LIFETIME_SECS,165+ nonce: [3; 16],166+ }167+ }168+169+ #[test]170+ fn a_well_formed_capability_verifies() {171+ let key = signing_key(9);172+ let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();173+ let verified = cap174+ .verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW + 1)175+ .unwrap();176+ assert_eq!(verified.permission, Permission::HttpRead);177+ }178+179+ #[test]180+ fn survives_a_round_trip_through_bytes() {181+ let key = signing_key(9);182+ let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();183+ let restored = SignedCapability::from_bytes(&cap.to_bytes()).unwrap();184+ assert!(restored185+ .verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW)186+ .is_ok());187+ }188+189+ #[test]190+ fn rejects_a_capability_signed_by_someone_else() {191+ let attacker = signing_key(1);192+ let real = signing_key(9);193+ let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &attacker).unwrap();194+ assert_eq!(195+ cap.verify_for(&real.verifying_key(), &[7; 32], &[8; 32], NOW),196+ Err(CapabilityError::BadSignature)197+ );198+ }199+200+ #[test]201+ fn rejects_use_from_a_different_browser_key() {202+ // Frank hands his capability to someone else, or it leaks from the network. The203+ // thief connects with their own endpoint key, and the binding fails.204+ let key = signing_key(9);205+ let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();206+ assert_eq!(207+ cap.verify_for(&key.verifying_key(), &[7; 32], &[99; 32], NOW),208+ Err(CapabilityError::WrongBrowserKey)209+ );210+ }211+212+ #[test]213+ fn rejects_replay_against_a_different_daemon() {214+ let key = signing_key(9);215+ let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();216+ assert_eq!(217+ cap.verify_for(&key.verifying_key(), &[123; 32], &[8; 32], NOW),218+ Err(CapabilityError::WrongAudience)219+ );220+ }221+222+ #[test]223+ fn rejects_expired_capabilities() {224+ let key = signing_key(9);225+ let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();226+ assert_eq!(227+ cap.verify_for(228+ &key.verifying_key(),229+ &[7; 32],230+ &[8; 32],231+ NOW + DEFAULT_LIFETIME_SECS + 1232+ ),233+ Err(CapabilityError::Expired)234+ );235+ }236+237+ #[test]238+ fn rejects_capabilities_from_the_future_beyond_skew() {239+ let key = signing_key(9);240+ let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();241+ // Within tolerance: fine.242+ assert!(cap243+ .verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW - 10)244+ .is_ok());245+ // Well beyond it: refuse.246+ assert_eq!(247+ cap.verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW - 600),248+ Err(CapabilityError::NotYetValid)249+ );250+ }251+252+ #[test]253+ fn rejects_tampered_claims() {254+ let key = signing_key(9);255+ let mut cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();256+ // Flip a byte in the signed payload; the signature must no longer match.257+ cap.claims[4] ^= 0xff;258+ assert_eq!(259+ cap.verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW),260+ Err(CapabilityError::BadSignature)261+ );262+ }263+}
A crates/devsite-proto/src/id.rs +140 -0
@@ -0,0 +1,140 @@
1+//! Immutable, opaque identifiers.2+//!3+//! Handles such as `@dami` are presentation names only. Every authorization decision in4+//! the system is made against these IDs, which never change and are never reassigned.5+6+use std::fmt;7+use std::str::FromStr;8+9+use serde::{Deserialize, Deserializer, Serialize, Serializer};10+11+const ENCODING: data_encoding::Encoding = data_encoding::BASE32_NOPAD;12+13+macro_rules! opaque_id {14+ ($name:ident, $prefix:literal) => {15+ #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]16+ pub struct $name([u8; 16]);17+18+ impl $name {19+ pub const PREFIX: &'static str = $prefix;20+21+ pub const fn from_bytes(bytes: [u8; 16]) -> Self {22+ Self(bytes)23+ }24+25+ /// Mint a fresh, unguessable id.26+ pub fn generate() -> Self {27+ let mut bytes = [0u8; 16];28+ getrandom::fill(&mut bytes).expect("system randomness is available");29+ Self(bytes)30+ }31+32+ pub fn as_bytes(&self) -> &[u8; 16] {33+ &self.034+ }35+ }36+37+ impl fmt::Display for $name {38+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {39+ write!(f, "{}_{}", $prefix, ENCODING.encode(&self.0).to_lowercase())40+ }41+ }42+43+ impl fmt::Debug for $name {44+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {45+ write!(f, "{}", self)46+ }47+ }48+49+ impl FromStr for $name {50+ type Err = IdParseError;51+52+ fn from_str(s: &str) -> Result<Self, Self::Err> {53+ let body = s54+ .strip_prefix(concat!($prefix, "_"))55+ .ok_or(IdParseError::BadPrefix { expected: $prefix })?;56+ let raw = ENCODING57+ .decode(body.to_uppercase().as_bytes())58+ .map_err(|_| IdParseError::BadEncoding)?;59+ let bytes: [u8; 16] = raw.try_into().map_err(|_| IdParseError::BadLength)?;60+ Ok(Self(bytes))61+ }62+ }63+64+ // Strings in JSON (readable API payloads), raw bytes in postcard (compact wire65+ // frames and stable signing input).66+ impl Serialize for $name {67+ fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {68+ if s.is_human_readable() {69+ s.serialize_str(&self.to_string())70+ } else {71+ self.0.serialize(s)72+ }73+ }74+ }75+76+ impl<'de> Deserialize<'de> for $name {77+ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {78+ if d.is_human_readable() {79+ let text = String::deserialize(d)?;80+ text.parse().map_err(serde::de::Error::custom)81+ } else {82+ <[u8; 16]>::deserialize(d).map(Self)83+ }84+ }85+ }86+ };87+}88+89+#[derive(Debug, thiserror::Error, PartialEq, Eq)]90+pub enum IdParseError {91+ #[error("expected an id beginning with `{expected}_`")]92+ BadPrefix { expected: &'static str },93+ #[error("id body is not valid base32")]94+ BadEncoding,95+ #[error("id body is not 16 bytes")]96+ BadLength,97+}98+99+opaque_id!(AccountId, "acct");100+opaque_id!(ResourceId, "res");101+102+#[cfg(test)]103+mod tests {104+ use super::*;105+106+ #[test]107+ fn round_trips_through_string() {108+ let id = ResourceId::from_bytes([7u8; 16]);109+ let text = id.to_string();110+ assert!(text.starts_with("res_"));111+ assert_eq!(ResourceId::from_str(&text).unwrap(), id);112+ }113+114+ #[test]115+ fn rejects_the_wrong_prefix() {116+ let account = AccountId::from_bytes([3u8; 16]).to_string();117+ // An AccountId must never parse as a ResourceId, or a confused-deputy bug becomes118+ // possible where one namespace's id authorizes access in the other.119+ assert_eq!(120+ ResourceId::from_str(&account),121+ Err(IdParseError::BadPrefix { expected: "res" })122+ );123+ }124+125+ #[test]126+ fn rejects_truncated_ids() {127+ assert_eq!(128+ ResourceId::from_str("res_aaaa"),129+ Err(IdParseError::BadLength)130+ );131+ }132+133+ #[test]134+ fn postcard_round_trip_is_compact() {135+ let id = ResourceId::from_bytes([9u8; 16]);136+ let bytes = postcard::to_allocvec(&id).unwrap();137+ assert_eq!(bytes.len(), 16, "ids must not be length-prefixed on the wire");138+ assert_eq!(postcard::from_bytes::<ResourceId>(&bytes).unwrap(), id);139+ }140+}
A crates/devsite-proto/src/lib.rs +14 -0
A crates/devsite-proto/src/wire.rs +158 -0
@@ -0,0 +1,158 @@
1+//! Frames exchanged over an Iroh bidirectional stream, and their codec.2+//!3+//! One request frame and one response frame per stream. Frames are `postcard`-encoded and4+//! prefixed with a u32 little-endian length.5+6+use serde::{Deserialize, Serialize};7+8+use crate::capability::SignedCapability;9+10+/// Hard ceiling on any single frame. Bounds daemon memory against a hostile peer: the11+/// length prefix is attacker-controlled, so it is checked *before* allocating.12+pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;13+14+#[derive(Debug, Clone, Serialize, Deserialize)]15+pub enum Request {16+ Http(HttpRequest),17+}18+19+#[derive(Debug, Clone, Serialize, Deserialize)]20+pub struct HttpRequest {21+ /// The grant authorizing this request. Required, not optional: a daemon must never22+ /// have a code path where an absent capability means "allow".23+ pub capability: SignedCapability,24+ pub method: Method,25+ /// Origin-relative path, e.g. `/` or `/chat`. Never an absolute URL — the daemon26+ /// resolves the origin from its own configuration, never from the peer.27+ pub path: String,28+}29+30+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]31+pub enum Method {32+ Get,33+ Head,34+}35+36+impl Method {37+ pub fn as_str(&self) -> &'static str {38+ match self {39+ Method::Get => "GET",40+ Method::Head => "HEAD",41+ }42+ }43+}44+45+#[derive(Debug, Clone, Serialize, Deserialize)]46+pub enum Response {47+ Http {48+ status: u16,49+ content_type: Option<String>,50+ body: Vec<u8>,51+ },52+ Error {53+ code: ErrorCode,54+ message: String,55+ },56+}57+58+/// Deliberately coarse. A peer that fails authorization learns only "denied" — never59+/// whether the resource existed, whether the signature or the binding was the problem, or60+/// anything else that would let it probe the daemon's configuration.61+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]62+pub enum ErrorCode {63+ Denied,64+ BadRequest,65+ UpstreamUnavailable,66+ UpstreamTooLarge,67+}68+69+#[derive(Debug, thiserror::Error)]70+pub enum CodecError {71+ #[error("frame of {size} bytes exceeds the {MAX_FRAME_BYTES} byte limit")]72+ TooLarge { size: usize },73+ #[error("malformed frame: {0}")]74+ Malformed(#[from] postcard::Error),75+}76+77+/// Encode a frame with its length prefix. Fails rather than emitting an oversized frame,78+/// so a bug on our side can't produce something the peer will reject as hostile.79+pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, CodecError> {80+ let payload = postcard::to_allocvec(value)?;81+ if payload.len() > MAX_FRAME_BYTES {82+ return Err(CodecError::TooLarge {83+ size: payload.len(),84+ });85+ }86+ let mut out = Vec::with_capacity(payload.len() + 4);87+ out.extend_from_slice(&(payload.len() as u32).to_le_bytes());88+ out.extend_from_slice(&payload);89+ Ok(out)90+}91+92+/// Validate a length prefix before it is used to size an allocation.93+pub fn frame_len(prefix: [u8; 4]) -> Result<usize, CodecError> {94+ let size = u32::from_le_bytes(prefix) as usize;95+ if size > MAX_FRAME_BYTES {96+ return Err(CodecError::TooLarge { size });97+ }98+ Ok(size)99+}100+101+pub fn decode<T: for<'de> Deserialize<'de>>(payload: &[u8]) -> Result<T, CodecError> {102+ Ok(postcard::from_bytes(payload)?)103+}104+105+#[cfg(test)]106+mod tests {107+ use super::*;108+109+ fn sample_capability() -> SignedCapability {110+ use crate::capability::{CapabilityClaims, Permission};111+ use crate::{AccountId, ResourceId};112+113+ let key = ed25519_dalek::SigningKey::from_bytes(&[5; 32]);114+ let claims = CapabilityClaims {115+ issuer: "https://dev.site".to_string(),116+ viewer: AccountId::from_bytes([1; 16]),117+ resource: ResourceId::from_bytes([2; 16]),118+ audience: [7; 32],119+ browser_key: [8; 32],120+ permission: Permission::HttpRead,121+ issued_at: 0,122+ expires_at: 300,123+ nonce: [3; 16],124+ };125+ SignedCapability::sign(&claims, &key).unwrap()126+ }127+128+ #[test]129+ fn frames_round_trip() {130+ let request = Request::Http(HttpRequest {131+ capability: sample_capability(),132+ method: Method::Get,133+ path: "/".to_string(),134+ });135+ let encoded = encode(&request).unwrap();136+ let size = frame_len(encoded[..4].try_into().unwrap()).unwrap();137+ assert_eq!(size, encoded.len() - 4);138+139+ let decoded: Request = decode(&encoded[4..]).unwrap();140+ let Request::Http(http) = decoded;141+ assert_eq!(http.path, "/");142+ assert_eq!(http.method, Method::Get);143+ }144+145+ #[test]146+ fn oversized_length_prefix_is_rejected_before_allocating() {147+ let prefix = (MAX_FRAME_BYTES as u32 + 1).to_le_bytes();148+ assert!(matches!(149+ frame_len(prefix),150+ Err(CodecError::TooLarge { .. })151+ ));152+ // The pathological case: a peer claiming ~4 GiB must not cause a 4 GiB allocation.153+ assert!(matches!(154+ frame_len(u32::MAX.to_le_bytes()),155+ Err(CodecError::TooLarge { .. })156+ ));157+ }158+}
A crates/devsite-server/Cargo.toml +28 -0
A crates/devsite-server/src/api.rs +606 -0
@@ -0,0 +1,606 @@
1+//! HTTP surface of the control plane.2+3+use std::str::FromStr;4+use std::sync::{Arc, Mutex};5+use std::time::{SystemTime, UNIX_EPOCH};6+7+use axum::extract::{Path, State};8+use axum::http::{header, HeaderMap, StatusCode};9+use axum::response::{IntoResponse, Response};10+use axum::routing::{get, post};11+use axum::{Json, Router};12+use devsite_proto::capability::KeyBytes;13+use devsite_proto::{AccountId, ResourceId};14+use serde::{Deserialize, Serialize};15+16+use crate::auth::{self, ShooVerifier};17+use crate::db::{Db, DaemonPresence, ResourceKind};18+use crate::issuer::Issuer;19+use crate::policy::{can_view, Visibility};20+21+pub struct AppState {22+ pub db: Mutex<Db>,23+ pub issuer: Issuer,24+ pub shoo: ShooVerifier,25+ pub public_origin: String,26+}27+28+pub type Shared = Arc<AppState>;29+30+pub fn now_secs() -> u64 {31+ SystemTime::now()32+ .duration_since(UNIX_EPOCH)33+ .map(|d| d.as_secs())34+ .unwrap_or(0)35+}36+37+// -- errors -------------------------------------------------------------------38+39+pub struct ApiError(StatusCode, String);40+41+impl ApiError {42+ fn bad_request(message: impl Into<String>) -> Self {43+ Self(StatusCode::BAD_REQUEST, message.into())44+ }45+ fn unauthorized() -> Self {46+ Self(StatusCode::UNAUTHORIZED, "sign in first".into())47+ }48+ /// Used wherever revealing existence would leak information.49+ fn not_found() -> Self {50+ Self(StatusCode::NOT_FOUND, "not found".into())51+ }52+ fn internal(err: anyhow::Error) -> Self {53+ tracing::error!("internal error: {err:#}");54+ Self(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())55+ }56+}57+58+impl IntoResponse for ApiError {59+ fn into_response(self) -> Response {60+ (self.0, Json(serde_json::json!({ "error": self.1 }))).into_response()61+ }62+}63+64+type ApiResult<T> = Result<T, ApiError>;65+66+// -- session extraction --------------------------------------------------------67+68+/// Resolve the caller's account from the session cookie or bearer token.69+///70+/// Returns `None` for anonymous callers rather than erroring, because public profiles are71+/// readable without signing in.72+fn current_account(state: &AppState, headers: &HeaderMap) -> ApiResult<Option<AccountId>> {73+ let token = bearer_token(headers).or_else(|| cookie_token(headers));74+ let Some(token) = token else {75+ return Ok(None);76+ };77+ let hash = auth::hash_token(&token);78+ let db = state.db.lock().unwrap();79+ let account = db80+ .session_account(&hash, now_secs())81+ .map_err(ApiError::internal)?;82+ Ok(account.map(|a| a.id))83+}84+85+fn require_account(state: &AppState, headers: &HeaderMap) -> ApiResult<AccountId> {86+ current_account(state, headers)?.ok_or_else(ApiError::unauthorized)87+}88+89+fn bearer_token(headers: &HeaderMap) -> Option<String> {90+ headers91+ .get(header::AUTHORIZATION)?92+ .to_str()93+ .ok()?94+ .strip_prefix("Bearer ")95+ .map(str::to_string)96+}97+98+fn cookie_token(headers: &HeaderMap) -> Option<String> {99+ headers100+ .get(header::COOKIE)?101+ .to_str()102+ .ok()?103+ .split(';')104+ .filter_map(|part| part.trim().split_once('='))105+ .find(|(name, _)| *name == "devsite_session")106+ .map(|(_, value)| value.to_string())107+}108+109+// -- payloads ------------------------------------------------------------------110+111+#[derive(Deserialize)]112+pub struct SignInRequest {113+ pub id_token: String,114+}115+116+#[derive(Serialize)]117+pub struct SignInResponse {118+ pub token: String,119+ pub account_id: String,120+ pub handle: Option<String>,121+}122+123+#[derive(Deserialize)]124+pub struct ClaimHandleRequest {125+ pub handle: String,126+}127+128+#[derive(Deserialize)]129+pub struct CreateResourceRequest {130+ pub name: String,131+ pub kind: String,132+ pub visibility: String,133+ #[serde(default)]134+ pub url: Option<String>,135+ /// Handles to share with. Only meaningful when `visibility` is `shared`.136+ #[serde(default)]137+ pub share_with: Vec<String>,138+}139+140+#[derive(Serialize)]141+pub struct CreateResourceResponse {142+ pub resource_id: String,143+}144+145+#[derive(Deserialize)]146+pub struct HeartbeatRequest {147+ pub endpoint_id: String,148+ pub relay_url: String,149+ /// The resources this daemon is currently configured to serve. A resource missing from150+ /// this list is reported offline even while the daemon itself is healthy.151+ #[serde(default)]152+ pub serving: Vec<String>,153+}154+155+#[derive(Serialize)]156+pub struct ProfileEntry {157+ pub resource_id: String,158+ pub name: String,159+ pub kind: &'static str,160+ pub visibility: &'static str,161+ pub url: Option<String>,162+ /// `None` for links, which have no daemon behind them.163+ pub online: Option<bool>,164+ /// Present only on "shared with me" entries.165+ #[serde(skip_serializing_if = "Option::is_none")]166+ pub owner_handle: Option<String>,167+}168+169+#[derive(Serialize)]170+pub struct ProfileResponse {171+ pub handle: String,172+ pub is_owner: bool,173+ pub entries: Vec<ProfileEntry>,174+ pub shared_with_me: Vec<ProfileEntry>,175+}176+177+#[derive(Deserialize)]178+pub struct CapabilityRequest {179+ pub resource_id: String,180+ pub browser_endpoint_id: String,181+}182+183+#[derive(Serialize)]184+pub struct CapabilityResponse {185+ /// base64url of the postcard-encoded signed capability.186+ pub capability: String,187+ pub daemon_endpoint_id: String,188+ pub relay_url: String,189+}190+191+// -- routes --------------------------------------------------------------------192+193+pub fn router(state: Shared) -> Router {194+ Router::new()195+ .route("/api/config", get(config))196+ .route("/api/pubkey", get(pubkey))197+ .route("/api/auth/session", post(sign_in))198+ .route("/api/me", get(me))199+ .route("/api/profile", post(claim_handle))200+ .route("/api/resources", post(create_resource).get(list_resources))201+ .route("/api/daemon/heartbeat", post(heartbeat))202+ .route("/api/profile/{handle}", get(profile))203+ .route("/api/capability", post(capability))204+ .with_state(state)205+}206+207+/// Public configuration the browser needs to start a sign-in.208+async fn config(State(state): State<Shared>) -> impl IntoResponse {209+ Json(serde_json::json!({210+ "issuer": auth::ISSUER,211+ "public_origin": state.public_origin,212+ "redirect_uri": format!("{}/auth/callback", state.public_origin),213+ }))214+}215+216+/// The capability-signing public key, pinned by daemons at login.217+async fn pubkey(State(state): State<Shared>) -> impl IntoResponse {218+ Json(serde_json::json!({ "public_key": state.issuer.public_key_hex() }))219+}220+221+async fn sign_in(222+ State(state): State<Shared>,223+ Json(body): Json<SignInRequest>,224+) -> ApiResult<Response> {225+ let claims = state226+ .shoo227+ .verify(&body.id_token)228+ .await229+ .map_err(|err| {230+ tracing::warn!("rejected an id_token: {err:#}");231+ ApiError(StatusCode::UNAUTHORIZED, "invalid sign-in token".into())232+ })?;233+234+ let now = now_secs();235+ let token = auth::generate_session_token();236+ let (account_id, handle) = {237+ let db = state.db.lock().unwrap();238+ let account = db239+ .upsert_account(&claims.sub, now)240+ .map_err(ApiError::internal)?;241+ db.create_session(242+ &auth::hash_token(&token),243+ account.id,244+ now + auth::SESSION_LIFETIME_SECS,245+ )246+ .map_err(ApiError::internal)?;247+ db.purge_expired_sessions(now).ok();248+ (account.id, account.handle)249+ };250+251+ // HttpOnly so page scripts cannot read it; SameSite=Lax so it survives the OAuth252+ // redirect back from Shoo without being sent on cross-site POSTs.253+ let cookie = format!(254+ "devsite_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}{}",255+ auth::SESSION_LIFETIME_SECS,256+ if state.public_origin.starts_with("https://") {257+ "; Secure"258+ } else {259+ ""260+ }261+ );262+263+ Ok((264+ StatusCode::OK,265+ [(header::SET_COOKIE, cookie)],266+ Json(SignInResponse {267+ token,268+ account_id: account_id.to_string(),269+ handle,270+ }),271+ )272+ .into_response())273+}274+275+async fn me(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<impl IntoResponse> {276+ let id = require_account(&state, &headers)?;277+ let db = state.db.lock().unwrap();278+ let account = db279+ .account_by_id(id)280+ .map_err(ApiError::internal)?281+ .ok_or_else(ApiError::unauthorized)?;282+ Ok(Json(serde_json::json!({283+ "account_id": account.id.to_string(),284+ "handle": account.handle,285+ })))286+}287+288+async fn claim_handle(289+ State(state): State<Shared>,290+ headers: HeaderMap,291+ Json(body): Json<ClaimHandleRequest>,292+) -> ApiResult<impl IntoResponse> {293+ let id = require_account(&state, &headers)?;294+ let handle = auth::validate_handle(&body.handle).map_err(|e| ApiError::bad_request(e.to_string()))?;295+296+ let db = state.db.lock().unwrap();297+ if let Some(existing) = db.account_by_handle(&handle).map_err(ApiError::internal)? {298+ if existing.id != id {299+ return Err(ApiError::bad_request("that handle is taken"));300+ }301+ }302+ db.set_handle(id, &handle).map_err(ApiError::internal)?;303+ Ok(Json(serde_json::json!({ "handle": handle })))304+}305+306+async fn create_resource(307+ State(state): State<Shared>,308+ headers: HeaderMap,309+ Json(body): Json<CreateResourceRequest>,310+) -> ApiResult<impl IntoResponse> {311+ let owner = require_account(&state, &headers)?;312+313+ let kind = match body.kind.as_str() {314+ "link" => ResourceKind::Link,315+ "service" => ResourceKind::Service,316+ other => return Err(ApiError::bad_request(format!("unknown kind `{other}`"))),317+ };318+ let visibility = Visibility::parse(&body.visibility)319+ .ok_or_else(|| ApiError::bad_request("visibility must be public, private or shared"))?;320+321+ // A link needs a URL; a service must not carry one. The local origin of a service stays322+ // in the owner's daemon config and is never uploaded.323+ match kind {324+ ResourceKind::Link if body.url.is_none() => {325+ return Err(ApiError::bad_request("a link needs a url"))326+ }327+ ResourceKind::Service if body.url.is_some() => {328+ return Err(ApiError::bad_request(329+ "a service's local origin stays on your machine and must not be sent here",330+ ))331+ }332+ _ => {}333+ }334+335+ let db = state.db.lock().unwrap();336+337+ // Resolve every share target *before* creating anything. Creating first and failing338+ // partway leaves an orphaned resource on the profile that the owner's daemon knows339+ // nothing about — it would advertise itself and then refuse every request.340+ let mut viewers = Vec::new();341+ for handle in &body.share_with {342+ let handle =343+ auth::validate_handle(handle).map_err(|e| ApiError::bad_request(e.to_string()))?;344+ let viewer = db345+ .account_by_handle(&handle)346+ .map_err(ApiError::internal)?347+ .ok_or_else(|| ApiError::bad_request(format!("no such user @{handle}")))?;348+ viewers.push(viewer.id);349+ }350+351+ let resource = db352+ .create_resource(owner, &body.name, kind, visibility, body.url.as_deref(), now_secs())353+ .map_err(ApiError::internal)?;354+355+ for viewer in viewers {356+ db.share_with(resource, viewer).map_err(ApiError::internal)?;357+ }358+359+ Ok(Json(CreateResourceResponse {360+ resource_id: resource.to_string(),361+ }))362+}363+364+async fn list_resources(365+ State(state): State<Shared>,366+ headers: HeaderMap,367+) -> ApiResult<impl IntoResponse> {368+ let owner = require_account(&state, &headers)?;369+ let db = state.db.lock().unwrap();370+ let resources = db.resources_owned_by(owner).map_err(ApiError::internal)?;371+ Ok(Json(serde_json::json!({372+ "resources": resources.iter().map(|r| serde_json::json!({373+ "resource_id": r.id.to_string(),374+ "name": r.name,375+ "kind": r.kind.as_str(),376+ "visibility": r.visibility.as_str(),377+ "url": r.url,378+ })).collect::<Vec<_>>()379+ })))380+}381+382+async fn heartbeat(383+ State(state): State<Shared>,384+ headers: HeaderMap,385+ Json(body): Json<HeartbeatRequest>,386+) -> ApiResult<impl IntoResponse> {387+ let account = require_account(&state, &headers)?;388+ // Validate before storing so a malformed relay url cannot poison later reads.389+ url::Url::parse(&body.relay_url).map_err(|_| ApiError::bad_request("relay_url is not a url"))?;390+ if parse_endpoint_id(&body.endpoint_id).is_none() {391+ return Err(ApiError::bad_request("endpoint_id is not a 32 byte hex key"));392+ }393+394+ let serving: Vec<ResourceId> = body395+ .serving396+ .iter()397+ .filter_map(|id| ResourceId::from_str(id).ok())398+ .collect();399+400+ let db = state.db.lock().unwrap();401+ db.record_heartbeat(402+ account,403+ &body.endpoint_id,404+ &body.relay_url,405+ &serving,406+ now_secs(),407+ )408+ .map_err(ApiError::internal)?;409+ Ok(StatusCode::NO_CONTENT)410+}411+412+async fn profile(413+ State(state): State<Shared>,414+ headers: HeaderMap,415+ Path(handle): Path<String>,416+) -> ApiResult<impl IntoResponse> {417+ let viewer = current_account(&state, &headers)?;418+ let handle = handle.trim_start_matches('@').to_ascii_lowercase();419+ let now = now_secs();420+421+ let db = state.db.lock().unwrap();422+ let owner = db423+ .account_by_handle(&handle)424+ .map_err(ApiError::internal)?425+ .ok_or_else(ApiError::not_found)?;426+427+ let presence = db.daemon(owner.id).map_err(ApiError::internal)?;428+429+ let mut entries = Vec::new();430+ for resource in db.resources_owned_by(owner.id).map_err(ApiError::internal)? {431+ let shared_with = db.shared_with(resource.id).map_err(ApiError::internal)?;432+ // The single choke point. A resource that fails here is not merely hidden from the433+ // rendering — it never enters the response at all.434+ if !can_view(viewer, owner.id, resource.visibility, &shared_with) {435+ continue;436+ }437+ let reachable = presence438+ .as_ref()439+ .is_some_and(|p| p.is_serving(resource.id, now));440+ entries.push(to_entry(&resource, Some(reachable), None));441+ }442+443+ // "Shared with me" belongs on your own profile, not on everyone else's. Listing it on444+ // @dami's page would also show Frank the same Agent entry twice.445+ let mut shared_with_me = Vec::new();446+ if let Some(viewer_id) = viewer.filter(|v| *v == owner.id) {447+ for resource in db448+ .resources_shared_with(viewer_id)449+ .map_err(ApiError::internal)?450+ {451+ let shared = db.shared_with(resource.id).map_err(ApiError::internal)?;452+ if !can_view(viewer, resource.owner_id, resource.visibility, &shared) {453+ continue;454+ }455+ let owner_account = db456+ .account_by_id(resource.owner_id)457+ .map_err(ApiError::internal)?;458+ let reachable = db459+ .daemon(resource.owner_id)460+ .map_err(ApiError::internal)?461+ .is_some_and(|p| p.is_serving(resource.id, now));462+ shared_with_me.push(to_entry(463+ &resource,464+ Some(reachable),465+ owner_account.and_then(|a| a.handle),466+ ));467+ }468+ }469+470+ Ok(Json(ProfileResponse {471+ handle,472+ is_owner: viewer == Some(owner.id),473+ entries,474+ shared_with_me,475+ }))476+}477+478+fn to_entry(479+ resource: &crate::db::Resource,480+ online: Option<bool>,481+ owner_handle: Option<String>,482+) -> ProfileEntry {483+ ProfileEntry {484+ resource_id: resource.id.to_string(),485+ name: resource.name.clone(),486+ kind: resource.kind.as_str(),487+ visibility: resource.visibility.as_str(),488+ url: resource.url.clone(),489+ online: match resource.kind {490+ ResourceKind::Link => None,491+ ResourceKind::Service => Some(online.unwrap_or(false)),492+ },493+ owner_handle,494+ }495+}496+497+/// Issue a capability, if and only if the caller may view the resource.498+///499+/// Denials are 404 rather than 403 throughout: a 403 would confirm that a resource id500+/// exists, which is enough to enumerate someone's private services.501+async fn capability(502+ State(state): State<Shared>,503+ headers: HeaderMap,504+ Json(body): Json<CapabilityRequest>,505+) -> ApiResult<impl IntoResponse> {506+ let viewer = require_account(&state, &headers)?;507+508+ let resource_id = ResourceId::from_str(&body.resource_id).map_err(|_| ApiError::not_found())?;509+ let browser_key =510+ parse_endpoint_id(&body.browser_endpoint_id).ok_or_else(|| ApiError::bad_request("browser_endpoint_id is not a 32 byte hex key"))?;511+512+ let (resource, presence) = {513+ let db = state.db.lock().unwrap();514+ let resource = db515+ .resource(resource_id)516+ .map_err(ApiError::internal)?517+ .ok_or_else(ApiError::not_found)?;518+ let shared_with = db.shared_with(resource_id).map_err(ApiError::internal)?;519+520+ if !can_view(Some(viewer), resource.owner_id, resource.visibility, &shared_with) {521+ tracing::warn!(522+ viewer = %viewer,523+ resource = %resource_id,524+ "refused a capability request"525+ );526+ return Err(ApiError::not_found());527+ }528+ if resource.kind != ResourceKind::Service {529+ return Err(ApiError::bad_request("that resource is a link, not a service"));530+ }531+ let presence = db.daemon(resource.owner_id).map_err(ApiError::internal)?;532+ (resource, presence)533+ };534+535+ let presence: DaemonPresence = presence.ok_or_else(|| {536+ ApiError(StatusCode::SERVICE_UNAVAILABLE, "that service is offline".into())537+ })?;538+ let now = now_secs();539+ // Not merely "is the daemon alive" — is it still exposing *this* resource. Issuing a540+ // capability for something the daemon has stopped serving just produces a confusing541+ // denial at the far end instead of an honest answer here.542+ if !presence.is_serving(resource.id, now) {543+ return Err(ApiError(544+ StatusCode::SERVICE_UNAVAILABLE,545+ "that service is offline".into(),546+ ));547+ }548+549+ let audience = parse_endpoint_id(&presence.endpoint_id)550+ .ok_or_else(|| ApiError::internal(anyhow::anyhow!("stored daemon endpoint id is invalid")))?;551+552+ let capability = state553+ .issuer554+ .issue(viewer, resource.id, audience, browser_key, now)555+ .map_err(ApiError::internal)?;556+557+ Ok(Json(CapabilityResponse {558+ capability: data_encoding::BASE64URL_NOPAD.encode(&capability.to_bytes()),559+ daemon_endpoint_id: presence.endpoint_id,560+ relay_url: presence.relay_url.to_string(),561+ }))562+}563+564+/// Parse a hex iroh endpoint id into raw key bytes.565+fn parse_endpoint_id(text: &str) -> Option<KeyBytes> {566+ let raw = data_encoding::HEXLOWER_PERMISSIVE567+ .decode(text.trim().as_bytes())568+ .ok()?;569+ raw.try_into().ok()570+}571+572+#[cfg(test)]573+mod tests {574+ use super::*;575+576+ #[test]577+ fn parses_a_valid_endpoint_id() {578+ let hex = "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7a0";579+ assert!(parse_endpoint_id(hex).is_some());580+ assert!(parse_endpoint_id(&hex.to_uppercase()).is_some());581+ }582+583+ #[test]584+ fn rejects_endpoint_ids_of_the_wrong_shape() {585+ for bad in ["", "abcd", "zz", "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7"] {586+ assert!(parse_endpoint_id(bad).is_none(), "{bad:?} should be rejected");587+ }588+ }589+590+ #[test]591+ fn reads_the_session_cookie_among_others() {592+ let mut headers = HeaderMap::new();593+ headers.insert(594+ header::COOKIE,595+ "theme=dark; devsite_session=abc123; other=x".parse().unwrap(),596+ );597+ assert_eq!(cookie_token(&headers).as_deref(), Some("abc123"));598+ }599+600+ #[test]601+ fn prefers_an_explicit_bearer_token() {602+ let mut headers = HeaderMap::new();603+ headers.insert(header::AUTHORIZATION, "Bearer cli-token".parse().unwrap());604+ assert_eq!(bearer_token(&headers).as_deref(), Some("cli-token"));605+ }606+}
A crates/devsite-server/src/auth.rs +221 -0

Not shown: this page already shows 6000 lines of diff.

A crates/devsite-server/src/db.rs +618 -0

Not shown: this page already shows 6000 lines of diff.

A crates/devsite-server/src/issuer.rs +171 -0

Not shown: this page already shows 6000 lines of diff.

A crates/devsite-server/src/main.rs +199 -0

Not shown: this page already shows 6000 lines of diff.

A crates/devsite-server/src/policy.rs +94 -0

Not shown: this page already shows 6000 lines of diff.

A crates/devsite-web/Cargo.toml +21 -0

Not shown: this page already shows 6000 lines of diff.

A crates/devsite-web/src/lib.rs +87 -0

Not shown: this page already shows 6000 lines of diff.

A deploy/README.md +57 -0

Not shown: this page already shows 6000 lines of diff.

A deploy/env +21 -0

Not shown: this page already shows 6000 lines of diff.

A deploy/run.sh +48 -0

Not shown: this page already shows 6000 lines of diff.

A deploy/setup-tunnel.sh +171 -0

Not shown: this page already shows 6000 lines of diff.

A docs/milestones.md +112 -0

Not shown: this page already shows 6000 lines of diff.

A fixtures/page-a.html +38 -0

Not shown: this page already shows 6000 lines of diff.

A fixtures/page-b.html +38 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/build-wasm.sh +88 -0

Not shown: this page already shows 6000 lines of diff.

A web/app.js +406 -0

Not shown: this page already shows 6000 lines of diff.

A web/index.html +38 -0

Not shown: this page already shows 6000 lines of diff.

A web/styles.css +369 -0

Not shown: this page already shows 6000 lines of diff.