- commit
- ff99393fc01128d57d34afb7ecce54047537f507
- parent
- c22dbd989823d353e2f66b8c3550ee1e0787b589
- author
- Dami <dami.etoile@gmail.com>
@@ -117,12 +117,6 @@ version = "1.0.104"
117117 source = "registry+https://github.com/rust-lang/crates.io-index"118118 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"119119 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-126120 [[package]]127121 name = "arc-swap"128122 version = "1.9.2"@@ -179,7 +173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
179173 checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"180174 dependencies = [181175 "base64",182- "http 1.5.0",176+ "http",183177 "log",184178 "url",185179 ]@@ -201,7 +195,7 @@ dependencies = [
201195 "bytes",202196 "form_urlencoded",203197 "futures-util",204- "http 1.5.0",198+ "http",205199 "http-body",206200 "http-body-util",207201 "hyper",@@ -232,7 +226,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
232226 dependencies = [233227 "bytes",234228 "futures-core",235- "http 1.5.0",229+ "http",236230 "http-body",237231 "http-body-util",238232 "mime",@@ -261,7 +255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
261255 checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"262256 dependencies = [263257 "fastrand",264- "gloo-timers 0.3.0",258+ "gloo-timers",265259 "tokio",266260 ]267261 @@ -283,15 +277,6 @@ version = "1.8.3"
283277 source = "registry+https://github.com/rust-lang/crates.io-index"284278 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"285279 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-295280 [[package]]296281 name = "bitflags"297282 version = "2.13.1"@@ -483,16 +468,6 @@ dependencies = [
483468 "memchr",484469 ]485470 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-496471 [[package]]497472 name = "const-oid"498473 version = "0.9.6"@@ -765,11 +740,12 @@ dependencies = [
765740 766741 [[package]]767742 name = "devsite-cli"768-version = "0.1.0"743+version = "0.2.0"769744 dependencies = [770745 "anyhow",771746 "clap",772747 "data-encoding",748+ "devsite-client",773749 "devsite-daemon",774750 "devsite-proto",775751 "iroh",@@ -779,12 +755,11 @@ dependencies = [
779755 "tokio",780756 "tracing",781757 "tracing-subscriber",782- "url",783758 ]784759 785760 [[package]]786761 name = "devsite-client"787-version = "0.1.0"762+version = "0.2.0"788763 dependencies = [789764 "anyhow",790765 "devsite-proto",@@ -796,7 +771,7 @@ dependencies = [
796771 797772 [[package]]798773 name = "devsite-daemon"799-version = "0.1.0"774+version = "0.2.0"800775 dependencies = [801776 "anyhow",802777 "data-encoding",@@ -806,26 +781,16 @@ dependencies = [
806781 "ed25519-dalek 2.2.0",807782 "getrandom 0.3.4",808783 "iroh",809- "reqwest 0.12.28",810784 "serde",811785 "serde_json",812786 "tokio",813787 "tracing",814788 "tracing-subscriber",815- "url",816-]817-818-[[package]]819-name = "devsite-fixture"820-version = "0.1.0"821-dependencies = [822- "anyhow",823- "tokio",824789 ]825790 826791 [[package]]827792 name = "devsite-proto"828-version = "0.1.0"793+version = "0.2.0"829794 dependencies = [830795 "data-encoding",831796 "ed25519-dalek 2.2.0",@@ -837,7 +802,7 @@ dependencies = [
837802 838803 [[package]]839804 name = "devsite-server"840-version = "0.1.0"805+version = "0.2.0"841806 dependencies = [842807 "anyhow",843808 "axum",@@ -858,23 +823,6 @@ dependencies = [
858823 "url",859824 ]860825 861-[[package]]862-name = "devsite-web"863-version = "0.1.0"864-dependencies = [865- "console_error_panic_hook",866- "data-encoding",867- "devsite-client",868- "devsite-proto",869- "getrandom 0.3.4",870- "iroh",871- "tracing",872- "tracing-subscriber",873- "tracing-subscriber-wasm",874- "wasm-bindgen",875- "wasm-bindgen-futures",876-]877-878826 [[package]]879827 name = "diatomic-waker"880828 version = "0.2.3"@@ -1275,11 +1223,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
12751223 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"12761224 dependencies = [12771225 "cfg-if",1278- "js-sys",12791226 "libc",12801227 "r-efi 5.3.0",12811228 "wasip2",1282- "wasm-bindgen",12831229 ]12841230 12851231 [[package]]@@ -1306,142 +1252,6 @@ dependencies = [
13061252 "polyval",13071253 ]13081254 1309-[[package]]1310-name = "gloo"1311-version = "0.8.1"1312-source = "registry+https://github.com/rust-lang/crates.io-index"1313-checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d"1314-dependencies = [1315- "gloo-console",1316- "gloo-dialogs",1317- "gloo-events",1318- "gloo-file",1319- "gloo-history",1320- "gloo-net",1321- "gloo-render",1322- "gloo-storage",1323- "gloo-timers 0.2.6",1324- "gloo-utils",1325- "gloo-worker",1326-]1327-1328-[[package]]1329-name = "gloo-console"1330-version = "0.2.3"1331-source = "registry+https://github.com/rust-lang/crates.io-index"1332-checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f"1333-dependencies = [1334- "gloo-utils",1335- "js-sys",1336- "serde",1337- "wasm-bindgen",1338- "web-sys",1339-]1340-1341-[[package]]1342-name = "gloo-dialogs"1343-version = "0.1.1"1344-source = "registry+https://github.com/rust-lang/crates.io-index"1345-checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6"1346-dependencies = [1347- "wasm-bindgen",1348- "web-sys",1349-]1350-1351-[[package]]1352-name = "gloo-events"1353-version = "0.1.2"1354-source = "registry+https://github.com/rust-lang/crates.io-index"1355-checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc"1356-dependencies = [1357- "wasm-bindgen",1358- "web-sys",1359-]1360-1361-[[package]]1362-name = "gloo-file"1363-version = "0.2.3"1364-source = "registry+https://github.com/rust-lang/crates.io-index"1365-checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7"1366-dependencies = [1367- "gloo-events",1368- "js-sys",1369- "wasm-bindgen",1370- "web-sys",1371-]1372-1373-[[package]]1374-name = "gloo-history"1375-version = "0.1.5"1376-source = "registry+https://github.com/rust-lang/crates.io-index"1377-checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f"1378-dependencies = [1379- "gloo-events",1380- "gloo-utils",1381- "serde",1382- "serde-wasm-bindgen",1383- "serde_urlencoded",1384- "thiserror 1.0.69",1385- "wasm-bindgen",1386- "web-sys",1387-]1388-1389-[[package]]1390-name = "gloo-net"1391-version = "0.3.1"1392-source = "registry+https://github.com/rust-lang/crates.io-index"1393-checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620"1394-dependencies = [1395- "futures-channel",1396- "futures-core",1397- "futures-sink",1398- "gloo-utils",1399- "http 0.2.12",1400- "js-sys",1401- "pin-project",1402- "serde",1403- "serde_json",1404- "thiserror 1.0.69",1405- "wasm-bindgen",1406- "wasm-bindgen-futures",1407- "web-sys",1408-]1409-1410-[[package]]1411-name = "gloo-render"1412-version = "0.1.1"1413-source = "registry+https://github.com/rust-lang/crates.io-index"1414-checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764"1415-dependencies = [1416- "wasm-bindgen",1417- "web-sys",1418-]1419-1420-[[package]]1421-name = "gloo-storage"1422-version = "0.2.2"1423-source = "registry+https://github.com/rust-lang/crates.io-index"1424-checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480"1425-dependencies = [1426- "gloo-utils",1427- "js-sys",1428- "serde",1429- "serde_json",1430- "thiserror 1.0.69",1431- "wasm-bindgen",1432- "web-sys",1433-]1434-1435-[[package]]1436-name = "gloo-timers"1437-version = "0.2.6"1438-source = "registry+https://github.com/rust-lang/crates.io-index"1439-checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"1440-dependencies = [1441- "js-sys",1442- "wasm-bindgen",1443-]1444-14451255 [[package]]14461256 name = "gloo-timers"14471257 version = "0.3.0"@@ -1454,36 +1264,6 @@ dependencies = [
14541264 "wasm-bindgen",14551265 ]14561266 1457-[[package]]1458-name = "gloo-utils"1459-version = "0.1.7"1460-source = "registry+https://github.com/rust-lang/crates.io-index"1461-checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"1462-dependencies = [1463- "js-sys",1464- "serde",1465- "serde_json",1466- "wasm-bindgen",1467- "web-sys",1468-]1469-1470-[[package]]1471-name = "gloo-worker"1472-version = "0.2.1"1473-source = "registry+https://github.com/rust-lang/crates.io-index"1474-checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a"1475-dependencies = [1476- "anymap2",1477- "bincode",1478- "gloo-console",1479- "gloo-utils",1480- "js-sys",1481- "serde",1482- "wasm-bindgen",1483- "wasm-bindgen-futures",1484- "web-sys",1485-]1486-14871267 [[package]]14881268 name = "h2"14891269 version = "0.4.15"@@ -1495,7 +1275,7 @@ dependencies = [
14951275 "fnv",14961276 "futures-core",14971277 "futures-sink",1498- "http 1.5.0",1278+ "http",14991279 "indexmap",15001280 "slab",15011281 "tokio",@@ -1553,7 +1333,7 @@ dependencies = [
15531333 "futures-util",15541334 "h2",15551335 "hickory-proto",1556- "http 1.5.0",1336+ "http",15571337 "idna",15581338 "ipnet",15591339 "jni 0.22.4",@@ -1615,17 +1395,6 @@ dependencies = [
16151395 "tracing",16161396 ]16171397 1618-[[package]]1619-name = "http"1620-version = "0.2.12"1621-source = "registry+https://github.com/rust-lang/crates.io-index"1622-checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"1623-dependencies = [1624- "bytes",1625- "fnv",1626- "itoa",1627-]1628-16291398 [[package]]16301399 name = "http"16311400 version = "1.5.0"@@ -1643,7 +1412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
16431412 checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"16441413 dependencies = [16451414 "bytes",1646- "http 1.5.0",1415+ "http",16471416 ]16481417 16491418 [[package]]@@ -1654,7 +1423,7 @@ checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
16541423 dependencies = [16551424 "bytes",16561425 "futures-core",1657- "http 1.5.0",1426+ "http",16581427 "http-body",16591428 "pin-project-lite",16601429 ]@@ -1697,7 +1466,7 @@ dependencies = [
16971466 "futures-channel",16981467 "futures-core",16991468 "h2",1700- "http 1.5.0",1469+ "http",17011470 "http-body",17021471 "httparse",17031472 "httpdate",@@ -1714,7 +1483,7 @@ version = "0.27.9"
17141483 source = "registry+https://github.com/rust-lang/crates.io-index"17151484 checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"17161485 dependencies = [1717- "http 1.5.0",1486+ "http",17181487 "hyper",17191488 "hyper-util",17201489 "rustls",@@ -1734,7 +1503,7 @@ dependencies = [
17341503 "bytes",17351504 "futures-channel",17361505 "futures-util",1737- "http 1.5.0",1506+ "http",17381507 "http-body",17391508 "hyper",17401509 "ipnet",@@ -1889,7 +1658,7 @@ dependencies = [
18891658 "attohttpc",18901659 "bytes",18911660 "futures",1892- "http 1.5.0",1661+ "http",18931662 "http-body-util",18941663 "hyper",18951664 "hyper-util",@@ -1958,7 +1727,7 @@ dependencies = [
19581727 "futures-util",19591728 "getrandom 0.4.3",19601729 "hickory-resolver",1961- "http 1.5.0",1730+ "http",19621731 "ipnet",19631732 "iroh-base",19641733 "iroh-dns",@@ -2075,7 +1844,7 @@ dependencies = [
20751844 "derive_more",20761845 "getrandom 0.4.3",20771846 "hickory-resolver",2078- "http 1.5.0",1847+ "http",20791848 "http-body-util",20801849 "hyper",20811850 "hyper-util",@@ -3254,7 +3023,7 @@ dependencies = [
32543023 "base64",32553024 "bytes",32563025 "futures-core",3257- "http 1.5.0",3026+ "http",32583027 "http-body",32593028 "http-body-util",32603029 "hyper",@@ -3293,7 +3062,7 @@ dependencies = [
32933062 "bytes",32943063 "futures-core",32953064 "futures-util",3296- "http 1.5.0",3065+ "http",32973066 "http-body",32983067 "http-body-util",32993068 "hyper",@@ -3541,17 +3310,6 @@ dependencies = [
35413310 "serde_derive",35423311 ]35433312 3544-[[package]]3545-name = "serde-wasm-bindgen"3546-version = "0.5.0"3547-source = "registry+https://github.com/rust-lang/crates.io-index"3548-checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e"3549-dependencies = [3550- "js-sys",3551- "serde",3552- "wasm-bindgen",3553-]3554-35553313 [[package]]35563314 name = "serde_bytes"35573315 version = "0.11.19"@@ -4089,7 +3847,7 @@ dependencies = [
40893847 "futures-core",40903848 "futures-sink",40913849 "getrandom 0.4.3",4092- "http 1.5.0",3850+ "http",40933851 "httparse",40943852 "rand 0.10.2",40953853 "ring",@@ -4157,7 +3915,7 @@ dependencies = [
41573915 "bytes",41583916 "futures-core",41593917 "futures-util",4160- "http 1.5.0",3918+ "http",41613919 "http-body",41623920 "http-body-util",41633921 "http-range-header",@@ -4249,17 +4007,6 @@ dependencies = [
42494007 "tracing-log",42504008 ]42514009 4252-[[package]]4253-name = "tracing-subscriber-wasm"4254-version = "0.1.0"4255-source = "registry+https://github.com/rust-lang/crates.io-index"4256-checksum = "79804e80980173c6c8e53d98508eb24a2dbc4ee17a3e8d2ca8e5bad6bf13a898"4257-dependencies = [4258- "gloo",4259- "tracing",4260- "tracing-subscriber",4261-]4262-42634010 [[package]]42644011 name = "try-lock"42654012 version = "0.2.5"@@ -1,29 +1,10 @@
11 # The control plane, built from source in one go.22 #3-# Both halves are built here rather than uploaded from a laptop: web/pkg/ is4-# gitignored, so a deploy that copied it in would ship whatever happened to be on5-# the machine that ran `fly deploy`. The image is the build.6-73 FROM rust:1.96-bookworm AS build84 9-# `ring` (via iroh's tls-ring feature) compiles C for wasm32, so the wasm build10-# needs a clang with the WebAssembly backend. Debian's has one; Apple's does not,11-# which is what scripts/build-wasm.sh goes looking for on a laptop.12-RUN apt-get update \13- && apt-get install -y --no-install-recommends clang llvm \14- && rm -rf /var/lib/apt/lists/*15-16-RUN rustup target add wasm32-unknown-unknown17-# The installer script, rather than `cargo install wasm-pack`, which builds it18-# from source and roughly doubles the length of a cold image build.19-RUN curl -sSfL https://rustwasm.github.io/wasm-pack/installer/init.sh | sh20-215 WORKDIR /src226 COPY . .237 24-# --release: the bundle is ~4x smaller than the dev build and this is the copy25-# every visitor downloads.26-RUN ./scripts/build-wasm.sh --release278 RUN cargo build --release -p devsite-server289 2910 FROM debian:bookworm-slim@@ -1,182 +1,187 @@
11 # dev.site22 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.3+dev.site is a profile for public links and private TCP services. A service stays on the4+machine that owns it. The machine opens an outbound Iroh connection, and an authorized5+viewer uses the `devsite` CLI to map that service onto a loopback port.6+7+Version 0.2 intentionally replaces the early single-page HTTP fetch experiment. Services8+are protocol-agnostic TCP byte streams; they are not assumed to be websites.9+10+## Shape11+12+```text13+viewer application14+ │ TCP to 127.0.0.1:ephemeral15+ ▼16+devsite connect17+ │ capability-gated Iroh QUIC stream18+ ▼19+owner's devsite daemon20+ │ TCP to 127.0.0.1:PORT21+ ▼22+private service23+24+dev.site control plane ── identity, profiles, approved shares, capability signing25+```526 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`.27+The control plane never carries service bytes and never learns the local port. It stores28+resource metadata and permissions, mints browser-requested connection tickets, then signs29+one short-lived capability per connection.30+The daemon verifies the signature, daemon audience, client endpoint key, resource id,31+expiry, and one-use nonce before opening the configured loopback target.1032 11-## How it works33+The transport ALPN is `devsite/tcp/1`.1234 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}19- ▼20-browser ──── 3. Iroh, end-to-end encrypted via relay ────► your daemon21- (address resolved from the daemon's own22- published record, not from dev.site)23- │ 4. verify24- ▼ signature · audience ·25- expiry · browser key vs26- the authenticated peer ·27- resource in local config28- 127.0.0.1:410129-```35+## Commands3036 31-The browser never names an upstream URL. The daemon resolves the origin from its own32-config, keyed by resource id — that is what keeps it from being an open proxy. The control33-plane holds permissions but never carries service traffic.37+After signing in at <https://dev.site>, create a named machine credential on the dashboard:3438 35-It is not a directory either. It stores a daemon's endpoint id — written once, because the36-id is the public half of a key on disk and does not change — and nothing about where that37-endpoint is or whether it is up. Iroh already answers both: the daemon publishes its own38-address, and the browser resolves it over HTTPS. There is no heartbeat and no presence39-service. Whether a service is running is discovered by asking it.39+```bash40+devsite login dsm_...41+```4042 41-## Prerequisites43+Expose a loopback TCP port. Services default to private:4244 43-- Rust stable + `rustup target add wasm32-unknown-unknown`44-- `cargo install wasm-pack`45-- A clang with the WebAssembly backend (`ring` compiles C). Apple's system clang does not46- have one — on macOS run `brew install llvm`. `scripts/build-wasm.sh` finds it.45+```bash46+devsite expose 300047+# exposed port 3000 → 127.0.0.1:3000 (private)48+# https://dev.site/s/res_...49+```4750 48-## Running it locally51+Names and folders are presentation. Port exposures enter the `Services` folder unless a52+different folder is named:4953 5054 ```bash51-./scripts/build-wasm.sh55+devsite expose 5432 --name postgres --folder databases56+```5257 53-export DEVSITE_PUBLIC_ORIGIN=http://127.0.0.1:4000 # pins account identity, see below54-cargo run -p devsite-server58+Sharing remains per service. A recipient must approve the invitation on their dashboard:5559 56-cargo run -p devsite-fixture -- 4101 fixtures/page-a.html # Hermes57-cargo run -p devsite-fixture -- 4102 fixtures/page-b.html # Agent60+```bash61+devsite expose 6379 --name redis --folder databases --share @bob5862 ```5963 60-Sign in at <http://127.0.0.1:4000> (Google, via Shoo), or mint a session from the shell:64+A permitted viewer opens the service on dev.site, clicks **Get ticket**, and passes the65+single-use ticket to the CLI. Viewer machines do not need a saved login:6166 6267 ```bash63-cargo run -p devsite-server -- issue-session alice68+devsite connect dst_...69+# connected to postgres (https://dev.site/s/res_...)70+# listening on 127.0.0.1:431276471 ```6572 66-Then configure the machine:73+Choose a stable local port when an application expects one:6774 6875 ```bash69-devsite login --token <token>70-devsite profile create @alice71-devsite link add --name "Klot Ski" --url https://klot.ski --public72-devsite expose http://127.0.0.1:4101 --name "Hermes" --private73-devsite expose http://127.0.0.1:4102 --name "Agent" --share @bob74-devsite theme set my-theme.css75-devsite daemon run76+devsite connect dst_... --listen 127.0.0.1:1543277+psql -h 127.0.0.1 -p 154327678 ```7779 78-Sites can be filed into folders, which render as folds on the profile:80+The connector refuses non-loopback listeners. Stop exposing a service with:7981 8082 ```bash81-devsite link add --name "klot.ski" --url https://klot.ski --public --folder Games83+devsite unexpose postgres8284 ```8385 84-Re-running `link add` or `expose` with the same name edits that entry in place, and85-what the command names is the whole state of it: `--share @carol` means the86-resource is Carol's, not Carol's as well, and leaving `--folder` off takes it out87-of the folder it was in. To take something down:86+Public HTTP(S) links remain ordinary profile entries:8887 8988 ```bash90-devsite link remove "Klot Ski"91-devsite unexpose Hermes89+devsite link add --name klot.ski --url https://klot.ski --public --folder Games9290 ```9391 94-### The profile page92+## Daemon93+94+`devsite daemon run` is the portable foreground service entrypoint. A service manager only95+needs to keep that command alive:96+97+```bash98+devsite daemon run99+```95100 96-Semantic HTML styled by [Pico CSS], set in Open Sans at 400 and 700. Both are97-vendored under `web/vendor/`, so the page makes no third-party request.101+The endpoint identity persists in the devsite config directory. The daemon registers that102+public endpoint id with the control plane once at startup, publishes its address through103+Iroh, and reloads service targets from the local config every two seconds. Adding or104+removing a service does not require restarting it.98105 99-Personalisation is real CSS, but not a stylesheet: a theme is a list of100-assignments to named Pico variables, each checked against a declared value101-grammar before it is stored. No selectors, no properties, no `!important` — so a102-theme can recolour and re-space the page and can do nothing else. The template it103-applies to, the whitelist, and the grammars are in104-[`docs/profile-template.md`](docs/profile-template.md).106+`devsite status` prints the config location, pinned signing key, and locally served ports.105107 106-[Pico CSS]: https://picocss.com108+## Profiles and folders107109 108-### Configuration110+A profile is a list of ordinary links and TCP services. Folders are repeated labels on111+entries and exist only to group the UI; they are not authorization containers. Accepted112+shares appear as ordinary rows in those folders with their owner noted, not in a separate113+top-level sharing section. Every service retains its own visibility, invitation state,114+revocation, and capability checks.109115 110-| Variable | Default | Notes |111-| --- | --- | --- |112-| `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. |113-| `DEVSITE_BIND` | `127.0.0.1:4000` | |114-| `DEVSITE_DB` | `devsite.db` | |115-| `DEVSITE_STATE_DIR` | `.devsite-state` | Holds the capability signing key, when it is not supplied directly. |116-| `DEVSITE_SIGNING_KEY` | *(unset)* | The capability signing key as 64 hex characters. Takes precedence over the state directory; a bad value is fatal rather than a reason to generate a new key. For hosts where a secret store beats a file. |117-| `DEVSITE_HOME` | platform config dir | Daemon identity and config. Set it to run several daemons on one machine. |116+The signed-in homepage is the dashboard. It manages approved and pending shares, revocable117+machine credentials, the private-only profile setting, and logout. Themes remain a bounded118+list of approved Pico CSS variables rather than arbitrary CSS.118119 119-## Deploying120+## Local development120121 121-The control plane runs on Fly: one machine, one volume, SQLite on it. `fly.toml` and122-`Dockerfile` are the deployment; `fly deploy` builds both the wasm bundle and the binary123-inside the image, so nothing is uploaded from a laptop.122+Prerequisite: stable Rust.124123 125124 ```bash126-fly deploy125+export DEVSITE_PUBLIC_ORIGIN=http://127.0.0.1:4000126+cargo run -p devsite-server127127 ```128128 129-Two settings cannot be undone — the public origin, and the capability signing key, which is130-a Fly secret rather than a file so that losing a volume does not lose every daemon's trust.131-First-time setup, DNS, certificates and backups are in132-[`deploy/README.md`](deploy/README.md).129+For operator-only setup, mint a local browser session:130+131+```bash132+cargo run -p devsite-server -- issue-session alice133+```133134 134-## Layout135+Build and test everything:135136 136-| Path | What it is |137-| --- | --- |138-| `crates/devsite-proto` | Ids, capability token, wire frames. No I/O; compiles for wasm. |139-| `crates/devsite-client` | Viewer side of the data plane. Compiles native **and** wasm. |140-| `crates/devsite-web` | wasm-bindgen seam over `devsite-client`. |141-| `crates/devsite-daemon` | Iroh endpoint, capability verification, loopback proxy. |142-| `crates/devsite-server` | Control plane: axum + SQLite, Shoo auth, capability issuance. |143-| `crates/devsite-cli` | `devsite` — login, profile, link, expose, unexpose, theme, daemon run. |144-| `crates/devsite-fixture` | Serves one HTML file on a port; stands in for a real service. |145-| `web/` | The site. `web/vendor/` is Pico and Open Sans; `web/pkg/` is generated by `scripts/build-wasm.sh`. |137+```bash138+cargo test --workspace139+cargo build --release -p devsite-cli140+```146141 147-`devsite-client` compiling both ways is deliberate: the authorization matrix is proven by148-fast native tests running the exact code the browser executes.142+## Deployment149143 150-## Testing144+The control plane runs as one Fly machine with one attached volume and SQLite behind a145+mutex. `fly.toml` and `Dockerfile` describe it:151146 152147 ```bash153-cargo test --workspace148+fly deploy154149 ```155150 156-78 tests. The interesting ones are `crates/devsite-daemon/tests/authz.rs`, which drives157-real Iroh against a real local service and asserts that forged, expired, misaddressed,158-rebound, replayed and unknown-resource capabilities are each refused — and that a denial159-tells the caller nothing about which of those it was. `theme.rs` carries the other160-adversarial set: a theme that tries to become a stylesheet, escape its rule, or name a161-property outside the whitelist.162-163-## Security notes164-165-- Capabilities are bound to a browser endpoint key and checked against the connection's166- authenticated peer, so a stolen capability is useless without the matching private key.167-- Denials are deliberately indistinguishable from one another, and `/api/capability`168- returns 404 rather than 403, so resource ids cannot be enumerated.169-- `devsite expose` refuses public addresses: exposures are limited to loopback, private170- ranges and `.ts.net`, so a daemon cannot be turned into a launderer for its owner's IP.171-- Fetched pages render in `sandbox="allow-scripts"` **without** `allow-same-origin`, giving172- them an opaque origin with no access to dev.site's DOM, storage or cookies.173-- A profile theme is a whitelist of Pico variables with per-property value grammars, so174- the alphabet of a stored value cannot contain `<`, `"` or `}`. User styling cannot175- position, hide or overlay anything, and cannot make one profile impersonate another.176-- A share names the whole list, so `--share @carol` revokes whoever was named before rather177- than adding to them, and sharing with nobody takes it back entirely.178-- Deletes are scoped by owner inside the statement, not by a check beforehand, so a request179- naming someone else's resource removes nothing.180-- Session tokens are stored only as SHA-256 hashes.181-- `issue-session` is an operator command requiring shell access; there is no182- network-reachable authentication bypass.151+The traffic path scales independently because it does not pass through Fly. Iroh relays152+carry end-to-end encrypted QUIC between clients and daemons; the control plane only signs153+authorization metadata.154+155+The two durable identities are `DEVSITE_PUBLIC_ORIGIN`, which Shoo uses when deriving156+accounts, and `DEVSITE_SIGNING_KEY`, whose public half every daemon pins at login. Changing157+either is intentionally disruptive.158+159+## Crates160+161+| Path | Responsibility |162+| --- | --- |163+| `crates/devsite-proto` | Opaque ids, signed capabilities, and the TCP stream handshake. |164+| `crates/devsite-client` | Native Iroh viewer endpoint and authorized service streams. |165+| `crates/devsite-daemon` | Capability verification and fixed-target TCP forwarding. |166+| `crates/devsite-cli` | Login, profiles, links, expose/connect, themes, and daemon lifecycle. |167+| `crates/devsite-server` | Axum/SQLite control plane and capability issuance. |168+| `web/` | Semantic HTML, plain JavaScript, Pico CSS, and vendored fonts. |169+170+## Security boundaries171+172+- A peer supplies only a signed capability. It never supplies the daemon's target address.173+- `devsite expose PORT` always stores `127.0.0.1:PORT`; port zero is rejected.174+- `devsite connect` listens only on a loopback address.175+- Browser-minted tickets are short-lived, stored only as hashes, and consumed once. A176+ successful redemption becomes a client-key-bound tunnel session kept only in CLI memory.177+- Capabilities are bound to the authenticated Iroh client endpoint and cannot be replayed178+ by another endpoint.179+- Each capability opens one stream and its nonce is consumed once.180+- Unknown resources, invalid signatures, wrong audiences, wrong clients, expiry, and replay181+ all produce the same denial.182+- Service identifiers are locators, not bearer credentials. Visibility and accepted-share183+ state are checked whenever a new capability is requested.184+- The control plane rate-limits capability issuance and bounds profiles, names, links,185+ folders, credentials, and share lists.186+- Machine credentials and browser sessions are stored only as SHA-256 hashes and can be187+ revoked from the dashboard.@@ -10,14 +10,14 @@ path = "src/main.rs"
1010 1111 [dependencies]1212 devsite-proto.workspace = true13+devsite-client.workspace = true1314 devsite-daemon = { path = "../devsite-daemon" }1415 anyhow.workspace = true1516 clap = { version = "4", features = ["derive", "env"] }16-tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] }17+tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "net", "io-util"] }1718 reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }1819 serde.workspace = true1920 serde_json = "1"20-url = "2"2121 iroh = "1"2222 tracing.workspace = true2323 tracing-subscriber = { version = "0.3", features = ["env-filter"] }@@ -4,6 +4,7 @@ use anyhow::{bail, Context, Result};
44 use serde::de::DeserializeOwned;55 use serde::Serialize;66 7+78 pub struct ControlPlane {89 base: String,910 token: Option<String>,@@ -4,19 +4,32 @@ mod client;
44 55 use anyhow::{bail, Context, Result};66 use clap::{Parser, Subcommand};7-use devsite_daemon::config::{validate_origin, DaemonConfig, ExposedResource, Paths, Visibility};7+use devsite_client::{ServiceStream, ViewerEndpoint};8+use devsite_daemon::config::{DaemonConfig, ExposedResource, Paths, Visibility};89 use devsite_daemon::Daemon;10+use devsite_proto::SignedCapability;911 use serde::Deserialize;12+use std::net::{IpAddr, Ipv4Addr, SocketAddr};1013 use std::sync::Arc;11-use url::Url;14+use tokio::io::{copy, AsyncWriteExt};15+use tokio::net::{TcpListener, TcpStream};1216 1317 use crate::client::ControlPlane;1418 1519 16-20+21+22+23+24+1725 struct Cli {1826 /// Control plane base URL.19- 27+ 28+29+30+31+32+2033 server: String,2134 2235 @@ -28,7 +41,6 @@ enum Command {
2841 /// Store a session token from the website and pin the control plane's signing key.2942 Login {3043 /// Session token shown by the website after signing in.31- 3244 token: Option<String>,3345 },3446 /// Profile management.@@ -39,22 +51,26 @@ enum Command {
3951 Link(LinkCommand),4052 /// Expose a local service.4153 Expose {42- /// Local origin, e.g. http://127.0.0.1:410143- origin: Url,54+ /// Local TCP port on 127.0.0.1.55+ port: u16,56+ /// Presentation name. Defaults to `port PORT`.4457 45- name: String,46- )]47- public: bool,48- )]49- private: bool,50- /// Share with a specific user, e.g. --share @bob. Repeatable.58+ name: Option<String>,59+ /// Invite a specific user to accept the share, e.g. --share @bob. Repeatable.5160 5261 share: Vec<String>,53- /// File it under a folder on your profile. Leaving it off takes it out of54- /// whatever folder it was in.62+ /// Presentation folder. TCP services default to `Services`.5563 5664 folder: Option<String>,5765 },66+ /// Forward a local TCP port to a service you may access.67+ Connect {68+ /// Short-lived connection ticket minted on dev.site.69+ ticket: String,70+ /// Local address to listen on. Port 0 chooses a free port.71+ 72+ listen: SocketAddr,73+ },5874 /// Stop exposing a local service and take it off your profile.5975 Unexpose {6076 /// The name it was exposed under.@@ -136,6 +152,12 @@ struct PubKeyResponse {
136152 public_key: String,137153 }138154 155+156+struct PublicConfig {157+ api_version: u32,158+ minimum_cli_version: String,159+}160+139161 140162 struct HandleResponse {141163 handle: String,@@ -146,6 +168,20 @@ struct CreateResourceResponse {
146168 resource_id: String,147169 }148170 171+172+struct CapabilityResponse {173+ capability: String,174+ daemon_endpoint_id: String,175+}176+177+178+struct RedeemTicketResponse {179+ session_token: String,180+ resource_id: String,181+ name: String,182+ expires_at: u64,183+}184+149185 150186 struct ResourceListing {151187 resources: Vec<Resource>,@@ -156,7 +192,7 @@ struct Resource {
156192 resource_id: String,157193 name: String,158194 kind: String,159- /// Set for links only; a service's origin never leaves the machine serving it.195+ /// Set for links only; a service's target never leaves the machine serving it.160196 url: Option<String>,161197 }162198 @@ -178,21 +214,23 @@ async fn main() -> Result<()> {
178214 Command::Profile(ProfileCommand::Create { handle }) => {179215 create_profile(&paths, &cli.server, &handle).await180216 }181- Command::Link(LinkCommand::Add { name, url, public, folder }) => {182- add_link(&paths, &cli.server, &name, &url, public, folder).await183- }217+ Command::Link(LinkCommand::Add {218+ name,219+ url,220+ public,221+ folder,222+ }) => add_link(&paths, &cli.server, &name, &url, public, folder).await,184223 Command::Link(LinkCommand::Remove { name }) => {185224 remove_link(&paths, &cli.server, &name).await186225 }187226 Command::Unexpose { name } => unexpose(&paths, &cli.server, &name).await,188227 Command::Expose {189- origin,228+ port,190229 name,191- public,192- private,193230 share,194231 folder,195- } => expose(&paths, &cli.server, origin, &name, public, private, share, folder).await,232+ } => expose(&paths, &cli.server, port, name, share, folder).await,233+ Command::Connect { ticket, listen } => connect(&cli.server, &ticket, listen).await,196234 Command::Theme(command) => theme(&paths, &cli.server, command).await,197235 Command::Status => status(&paths),198236 Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server).await,@@ -201,7 +239,7 @@ async fn main() -> Result<()> {
201239 202240 fn load_authenticated(paths: &Paths) -> Result<DaemonConfig> {203241 let config = paths.load_config()?;204- if config.session_token.is_none() {242+ if config.machine_credential.is_none() {205243 bail!("not signed in — run `devsite login` first");206244 }207245 Ok(config)@@ -211,7 +249,7 @@ async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()>
211249 let token = match token {212250 Some(token) => token,213251 None => {214- println!("Sign in at {server} and paste the session token shown there.");252+ println!("Open {server}, create a machine credential, and paste it here.");215253 print!("token: ");216254 use std::io::Write;217255 std::io::stdout().flush()?;@@ -225,8 +263,15 @@ async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()>
225263 }226264 227265 let api = ControlPlane::new(server, Some(token.clone()));228- // Confirm the token works before storing it, so a typo fails here rather than at the229- // first API call.266+ let server_config: PublicConfig = api.get("/api/config").await?;267+ if server_config.api_version != 2 {268+ bail!(269+ "this CLI speaks API version 2, but the server reports version {}",270+ server_config.api_version271+ );272+ }273+ // Confirm the credential works before storing it, so a typo fails here274+ // rather than at the first API call.230275 let me: serde_json::Value = api231276 .get("/api/me")232277 .await@@ -235,7 +280,7 @@ async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()>
235280 236281 let mut config = paths.load_config()?;237282 config.server_url = Some(server.trim_end_matches('/').to_string());238- config.session_token = Some(token);283+ config.machine_credential = Some(token);239284 // Pinned now, and every capability is checked against it from here on.240285 config.control_plane_key = Some(pubkey.public_key);241286 paths.save_config(&config)?;@@ -245,13 +290,20 @@ async fn login(paths: &Paths, server: &str, token: Option<String>) -> Result<()>
245290 .and_then(|h| h.as_str())246291 .unwrap_or("(no handle yet)");247292 println!("signed in as {handle}");248- println!("pinned control plane key {}", config.control_plane_key.unwrap());293+ println!(294+ "server requires CLI {} or newer",295+ server_config.minimum_cli_version296+ );297+ println!(298+ "pinned control plane key {}",299+ config.control_plane_key.unwrap()300+ );249301 Ok(())250302 }251303 252304 async fn create_profile(paths: &Paths, server: &str, handle: &str) -> Result<()> {253305 let config = load_authenticated(paths)?;254- let api = ControlPlane::new(server, config.session_token.clone());306+ let api = ControlPlane::new(server, config.machine_credential.clone());255307 let response: HandleResponse = api256308 .post(257309 "/api/profile",@@ -274,7 +326,7 @@ async fn add_link(
274326 bail!("links are external URLs and are always public; pass --public to confirm");275327 }276328 let config = load_authenticated(paths)?;277- let api = ControlPlane::new(server, config.session_token.clone());329+ let api = ControlPlane::new(server, config.machine_credential.clone());278330 let _: CreateResourceResponse = api279331 .post(280332 "/api/resources",@@ -302,7 +354,7 @@ async fn add_link(
302354 /// such thing" case is answered with the names that do exist.303355 async fn remove_resource(paths: &Paths, server: &str, name: &str, kind: &str) -> Result<Resource> {304356 let config = load_authenticated(paths)?;305- let api = ControlPlane::new(server, config.session_token.clone());357+ let api = ControlPlane::new(server, config.machine_credential.clone());306358 307359 let listing: ResourceListing = api.get("/api/resources").await?;308360 let found = listing@@ -342,7 +394,7 @@ async fn unexpose(paths: &Paths, server: &str, name: &str) -> Result<()> {
342394 if config.resources.len() == before {343395 println!(" (this machine was not serving it; removed from your profile)");344396 } else {345- println!(" restart `devsite daemon run` to stop serving it");397+ println!(" a running daemon will stop serving it automatically");346398 }347399 Ok(())348400 }@@ -350,36 +402,34 @@ async fn unexpose(paths: &Paths, server: &str, name: &str) -> Result<()> {
350402 async fn expose(351403 paths: &Paths,352404 server: &str,353- origin: Url,354- name: &str,355- public: bool,356- private: bool,405+ port: u16,406+ name: Option<String>,357407 share: Vec<String>,358408 folder: Option<String>,359409 ) -> Result<()> {360- // Refuse to proxy to the public internet: dev.site exposes local services, and an361- // arbitrary upstream would make the daemon a traffic launderer.362- validate_origin(&origin)?;363-364- let visibility = match (public, private, share.is_empty()) {365- (true, false, true) => Visibility::Public,366- (false, _, true) => Visibility::Private,367- (false, false, false) => Visibility::Shared,368- (true, _, false) => bail!("--public and --share are mutually exclusive"),369- _ => bail!("choose one of --public, --private or --share"),410+ if port == 0 {411+ bail!("port 0 cannot be exposed");412+ }413+ let name = name.unwrap_or_else(|| format!("port {port}"));414+ let folder = Some(folder.unwrap_or_else(|| "Services".to_string()));415+ let target = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);416+ let visibility = if share.is_empty() {417+ Visibility::Private418+ } else {419+ Visibility::Shared370420 };371421 372422 let config = load_authenticated(paths)?;373- let api = ControlPlane::new(server, config.session_token.clone());423+ let api = ControlPlane::new(server, config.machine_credential.clone());374424 375425 let response: CreateResourceResponse = api376426 .post(377427 "/api/resources",378428 &serde_json::json!({379- "name": name,429+ "name": &name,380430 "kind": "service",381431 "visibility": visibility_str(visibility),382- // Deliberately absent: the local origin never leaves this machine.432+ // Deliberately absent: the local target never leaves this machine.383433 "share_with": share.iter().map(|h| h.trim_start_matches('@')).collect::<Vec<_>>(),384434 "folder": folder,385435 }),@@ -395,21 +445,107 @@ async fn expose(
395445 config.resources.retain(|r| r.name != name);396446 config.resources.push(ExposedResource {397447 resource_id,398- name: name.to_string(),399- origin: origin.clone(),448+ name: name.clone(),449+ target,400450 visibility,401451 });402452 paths.save_config(&config)?;403453 404- println!("exposed {name} → {origin} ({})", visibility_str(visibility));454+ println!("exposed {name} → {target} ({})", visibility_str(visibility));405455 if let Some(folder) = &folder {406456 println!(" in {folder}");407457 }408458 if !share.is_empty() {409- println!(" shared with {}", share.join(", "));459+ println!(" invited {}", share.join(", "));410460 }411- println!(" resource id {resource_id}");412- println!("\nrun `devsite daemon run` to serve it.");461+ println!(" {server}/s/{resource_id}");462+ println!("\nrun `devsite daemon run` to serve it; an existing daemon reloads automatically.");463+ Ok(())464+}465+466+async fn connect(server: &str, ticket: &str, listen: SocketAddr) -> Result<()> {467+ if !listen.ip().is_loopback() {468+ bail!("--listen must be a loopback address; refusing to publish the tunnel on the LAN");469+ }470+ let viewer = Arc::new(ViewerEndpoint::create().await?);471+ let bootstrap = ControlPlane::new(server, None);472+ let redeemed: RedeemTicketResponse = bootstrap473+ .post(474+ "/api/tickets/redeem",475+ &serde_json::json!({476+ "ticket": ticket,477+ "client_endpoint_id": viewer.endpoint_id().to_string(),478+ }),479+ )480+ .await481+ .context("redeeming connection ticket")?;482+ let api = Arc::new(ControlPlane::new(server, Some(redeemed.session_token)));483+ let listener = TcpListener::bind(listen)484+ .await485+ .with_context(|| format!("binding local listener {listen}"))?;486+ let bound = listener.local_addr()?;487+488+ println!(489+ "connected to {} ({server}/s/{})",490+ redeemed.name, redeemed.resource_id491+ );492+ println!(" listening on {bound}");493+ println!(" session expires at unix time {}", redeemed.expires_at);494+ println!(" press Ctrl-C to stop");495+496+ loop {497+ let (local, peer) = tokio::select! {498+ accepted = listener.accept() => accepted.context("accepting local connection")?,499+ _ = tokio::signal::ctrl_c() => {500+ println!("\nshutting down");501+ if let Err(err) = api.delete("/api/tunnel/session").await {502+ tracing::warn!("could not revoke tunnel session during shutdown: {err:#}");503+ }504+ viewer.close().await;505+ return Ok(());506+ }507+ };508+ let api = Arc::clone(&api);509+ let viewer = Arc::clone(&viewer);510+ tokio::spawn(async move {511+ if let Err(err) = forward_connection(&api, &viewer, local).await {512+ tracing::warn!(%peer, "service connection failed: {err:#}");513+ }514+ });515+ }516+}517+518+async fn forward_connection(519+ api: &ControlPlane,520+ viewer: &ViewerEndpoint,521+ local: TcpStream,522+) -> Result<()> {523+ let grant: CapabilityResponse = api524+ .post("/api/tunnel/capability", &serde_json::json!({}))525+ .await?;526+ let raw = data_encoding::BASE64URL_NOPAD527+ .decode(grant.capability.as_bytes())528+ .context("the server returned a malformed capability")?;529+ let capability =530+ SignedCapability::from_bytes(&raw).context("the server returned a malformed capability")?;531+ let daemon: iroh::EndpointId = grant532+ .daemon_endpoint_id533+ .parse()534+ .context("the server returned an invalid daemon endpoint")?;535+ let ServiceStream { mut send, mut recv } = viewer.connect(daemon, capability).await?;536+ let (mut local_read, mut local_write) = local.into_split();537+538+ let local_to_service = async {539+ copy(&mut local_read, &mut send).await?;540+ send.finish().context("finishing service input")?;541+ Ok::<_, anyhow::Error>(())542+ };543+ let service_to_local = async {544+ copy(&mut recv, &mut local_write).await?;545+ local_write.shutdown().await.ok();546+ Ok::<_, anyhow::Error>(())547+ };548+ tokio::try_join!(local_to_service, service_to_local)?;413549 Ok(())414550 }415551 @@ -426,7 +562,7 @@ async fn theme(paths: &Paths, server: &str, command: ThemeCommand) -> Result<()>
426562 }427563 428564 let config = load_authenticated(paths)?;429- let api = ControlPlane::new(server, config.session_token.clone());565+ let api = ControlPlane::new(server, config.machine_credential.clone());430566 431567 match command {432568 ThemeCommand::Properties => unreachable!("handled above, before authentication"),@@ -447,12 +583,16 @@ async fn theme(paths: &Paths, server: &str, command: ThemeCommand) -> Result<()>
447583 // The server is the only validator. Rejections arrive as its own448584 // message — "`--pico-primary: wine` — expected a colour, e.g. …" —449585 // and are printed as-is rather than re-worded here.450- let saved: ThemeResponse = api.put("/api/theme", &serde_json::json!({ "css": css })).await?;586+ let saved: ThemeResponse = api587+ .put("/api/theme", &serde_json::json!({ "css": css }))588+ .await?;451589 print!("{}", saved.css);452590 println!("theme saved");453591 }454592 ThemeCommand::Clear => {455- let _: ThemeResponse = api.put("/api/theme", &serde_json::json!({ "css": "" })).await?;593+ let _: ThemeResponse = api594+ .put("/api/theme", &serde_json::json!({ "css": "" }))595+ .await?;456596 println!("theme cleared");457597 }458598 }@@ -477,7 +617,10 @@ fn status(paths: &Paths) -> Result<()> {
477617 );478618 println!(479619 "signing key {}",480- config.control_plane_key.as_deref().unwrap_or("(not pinned)")620+ config621+ .control_plane_key622+ .as_deref()623+ .unwrap_or("(not pinned)")481624 );482625 483626 if config.resources.is_empty() {@@ -490,7 +633,7 @@ fn status(paths: &Paths) -> Result<()> {
490633 " {:<16} {:<10} {}",491634 resource.name,492635 visibility_str(resource.visibility),493- resource.origin636+ resource.target494637 );495638 }496639 Ok(())@@ -498,8 +641,11 @@ fn status(paths: &Paths) -> Result<()> {
498641 499642 async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {500643 let config = load_authenticated(paths)?;501- let server_url = config.server_url.clone().unwrap_or_else(|| server.to_string());502- let token = config.session_token.clone();644+ let server_url = config645+ .server_url646+ .clone()647+ .unwrap_or_else(|| server.to_string());648+ let token = config.machine_credential.clone();503649 504650 let secret_key = paths.load_or_create_identity()?;505651 let daemon = Arc::new(Daemon::bind(secret_key, config).await?);@@ -533,9 +679,41 @@ async fn run_daemon(paths: &Paths, server: &str) -> Result<()> {
533679 .await534680 .context("registering this daemon with the control plane")?;535681 682+ let reload_paths = Paths {683+ root: paths.root.clone(),684+ };536685 tokio::select! {537686 result = Arc::clone(&daemon).serve() => result?,687+ result = reload_config(reload_paths, Arc::clone(&daemon)) => result?,538688 _ = tokio::signal::ctrl_c() => println!("\nshutting down"),539689 }690+ daemon.close().await;540691 Ok(())541692 }693+694+async fn reload_config(paths: Paths, daemon: Arc<Daemon>) -> Result<()> {695+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));696+ interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);697+ let mut last_error = None;698+ // The initial config was loaded immediately before the daemon started; the699+ // first useful tick is the next one.700+ interval.tick().await;701+ loop {702+ interval.tick().await;703+ match paths.load_config() {704+ Ok(config) => {705+ last_error = None;706+ if daemon.replace_resources(config.resources).await {707+ println!("reloaded exposed services");708+ }709+ }710+ Err(err) => {711+ let message = format!("{err:#}");712+ if last_error.as_deref() != Some(message.as_str()) {713+ tracing::warn!("could not reload daemon config: {message}");714+ last_error = Some(message);715+ }716+ }717+ }718+ }719+}@@ -7,8 +7,7 @@ license.workspace = true
77 [dependencies]88 devsite-proto.workspace = true99 iroh = { version = "1", default-features = false, features = ["tls-ring"] }10-# Timers that work natively and in the browser. tokio::time does not exist on11-# wasm32-unknown-unknown, and the connect timeout has to hold on both.10+# Iroh's runtime-neutral timeout keeps connection setup bounded.1211 n0-future = "0.3"1312 anyhow.workspace = true1413 tracing.workspace = true@@ -1,9 +1,6 @@
11 //! The viewer side of the dev.site data plane.22 //!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.3+//! Each accepted local TCP connection becomes one authenticated Iroh bidirectional stream.74 85 use std::collections::HashMap;96 use std::sync::Mutex;@@ -11,7 +8,7 @@ use std::time::Duration;
118 129 use anyhow::{Context, Result};1310 use devsite_proto::capability::SignedCapability;14-use devsite_proto::wire::{self, ErrorCode, HttpRequest, Method, Request, Response};11+use devsite_proto::wire::{self, ConnectRequest, ErrorCode, Request, Response};1512 use devsite_proto::ALPN;1613 use iroh::endpoint::Connection;1714 use iroh::{Endpoint, EndpointAddr, EndpointId};@@ -25,13 +22,13 @@ use n0_future::time::timeout;
2522 /// a dead daemon does not leave someone watching a spinner.2623 pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);2724 28-/// Why a fetch did not produce a page.25+/// Why a service stream could not be opened.2926 ///3027 /// Transport failure and refusal are kept distinct so a caller — and especially a test —3128 /// can tell "the daemon said no" from "we never reached the daemon". Collapsing the two3229 /// would let a network timeout masquerade as a successful authorization check.3330 34-pub enum FetchError {31+pub enum ConnectError {3532 /// The daemon reached a verdict and refused.3633 3734 Denied,@@ -43,43 +40,34 @@ pub enum FetchError {
4340 Transport( anyhow::Error),4441 }4542 46-impl From<wire::CodecError> for FetchError {43+impl From<wire::CodecError> for ConnectError {4744 fn from(err: wire::CodecError) -> Self {48- FetchError::Transport(err.into())45+ ConnectError::Transport(err.into())4946 }5047 }5148 52-/// A page fetched from a remote daemon.53-54-pub struct FetchedPage {55- pub status: u16,56- pub content_type: Option<String>,57- pub body: Vec<u8>,49+/// The two halves of an authorized service byte stream.50+pub struct ServiceStream {51+ pub send: iroh::endpoint::SendStream,52+ pub recv: iroh::endpoint::RecvStream,5853 }5954 60-impl FetchedPage {61- /// The body as text. Used to hand HTML to a sandboxed iframe.62- pub fn text(&self) -> String {63- String::from_utf8_lossy(&self.body).into_owned()64- }65-}66-67-/// A viewer endpoint. In the browser this is ephemeral — a fresh key per tab session — so68-/// a capability issued for it dies with the page.55+/// An ephemeral viewer endpoint. A capability is bound to this key and therefore cannot56+/// be redeemed by another devsite client.6957 pub struct ViewerEndpoint {7058 endpoint: Endpoint,71- /// One live connection per daemon, reused across requests.59+ /// One live connection per daemon, reused across service streams.7260 ///7361 /// Connecting per request does not work: dropping a `Connection` closes it, and the74- /// endpoint hands the same closed connection back to the next `connect` for that peer,75- /// which then fails with "closed". Holding it open is also simply what a browsing76- /// session wants — several pages from one daemon over one relay connection.62+ /// endpoint hands the same closed connection back to the next connection for that peer,63+ /// which then fails with "closed". Holding it open also lets many local connections64+ /// share one authenticated QUIC connection.7765 connections: Mutex<HashMap<EndpointId, Connection>>,7866 }7967 8068 impl ViewerEndpoint {8169 /// Bind a new endpoint with a freshly generated key and wait until a relay has82- /// accepted us. Browsers have no direct UDP path, so the relay is the only way out.70+ /// accepted us.8371 pub async fn create() -> Result<Self> {8472 let endpoint = Endpoint::builder(iroh::endpoint::presets::N0)8573 .bind()@@ -98,39 +86,33 @@ impl ViewerEndpoint {
9886 self.endpoint.id()9987 }10088 101- /// Fetch a page from a daemon, presenting `capability`.89+ /// Close the ephemeral endpoint and every cached daemon connection cleanly.90+ pub async fn close(&self) {91+ self.endpoint.close().await;92+ }93+94+ /// Open one authorized byte stream to a service behind a daemon.10295 ///10396 /// `daemon` is normally a bare [`EndpointId`]: the daemon publishes its own104- /// address through iroh's address lookup, and the browser resolves it over105- /// HTTPS. Nothing has to tell us where it is. Callers that already know an97+ /// address through iroh's address lookup, and the client resolves it. Nothing has to98+ /// tell us where it is. Callers that already know an10699 /// address — the tests, which want a relay pinned rather than a lookup — can107100 /// pass a fuller [`EndpointAddr`].108101 ///109102 /// The capability must have been issued for this endpoint's key; the daemon checks110103 /// that binding against the connection and will refuse otherwise.111- pub async fn fetch(104+ pub async fn connect(112105 &self,113106 daemon: impl Into<EndpointAddr>,114107 capability: SignedCapability,115- path: &str,116- ) -> Result<FetchedPage, FetchError> {108+ ) -> Result<ServiceStream, ConnectError> {117109 let addr = daemon.into();118110 let connection = self.connection_to(&addr).await?;119- match request_page(&connection, capability.clone(), path).await {120- Err(FetchError::Transport(err)) => {121- // The cached connection may simply have aged out. Drop it and try once122- // more on a fresh one before reporting failure.123- tracing::debug!("retrying on a fresh connection: {err:#}");124- self.connections.lock().unwrap().remove(&addr.id);125- let connection = self.connection_to(&addr).await?;126- request_page(&connection, capability, path).await127- }128- other => other,129- }111+ request_stream(&connection, capability).await130112 }131113 132114 /// A live connection to `daemon`, reusing the cached one when there is one.133- async fn connection_to(&self, daemon: &EndpointAddr) -> Result<Connection, FetchError> {115+ async fn connection_to(&self, daemon: &EndpointAddr) -> Result<Connection, ConnectError> {134116 // Scoped so the guard is released before the await below. An `if let` scrutinee135117 // temporary would otherwise live to the end of the whole statement, and the136118 // second lock in this function would deadlock against it.@@ -156,25 +138,19 @@ impl ViewerEndpoint {
156138 }157139 }158140 159-async fn request_page(141+async fn request_stream(160142 connection: &Connection,161143 capability: SignedCapability,162- path: &str,163-) -> Result<FetchedPage, FetchError> {144+) -> Result<ServiceStream, ConnectError> {164145 let (mut send, mut recv) = connection165146 .open_bi()166147 .await167148 .context("opening bidirectional stream")?;168149 169- let request = Request::Http(HttpRequest {170- capability,171- method: Method::Get,172- path: path.to_string(),173- });150+ let request = Request::Connect(ConnectRequest { capability });174151 send.write_all(&wire::encode(&request)?)175152 .await176- .context("writing request frame")?;177- send.finish().context("finishing request stream")?;153+ .context("writing connect frame")?;178154 179155 let mut prefix = [0u8; 4];180156 recv.read_exact(&mut prefix)@@ -188,19 +164,11 @@ async fn request_page(
188164 189165 let response = wire::decode::<Response>(&payload).context("decoding response frame")?;190166 match response {191- Response::Http {192- status,193- content_type,194- body,195- } => Ok(FetchedPage {196- status,197- content_type,198- body,199- }),167+ Response::Connected => Ok(ServiceStream { send, recv }),200168 Response::Error {201169 code: ErrorCode::Denied,202170 ..203- } => Err(FetchError::Denied),204- Response::Error { code, .. } => Err(FetchError::Rejected(code)),171+ } => Err(ConnectError::Denied),172+ Response::Error { code, .. } => Err(ConnectError::Rejected(code)),205173 }206174 }@@ -10,9 +10,7 @@ iroh = "1"
1010 anyhow.workspace = true1111 tracing.workspace = true1212 tracing-subscriber = { version = "0.3", features = ["env-filter"] }13-tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }14-reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }15-url = "2"13+tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "net", "io-util"] }1614 ed25519-dalek = "2"1715 serde.workspace = true1816 serde_json = "1"@@ -1,18 +1,18 @@
11 //! Daemon state: its identity, the control plane it trusts, and the resources it serves.22 //!33 //! 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, and4+//! proxy. A capability names a *resource id*; only this map turns that into a TCP target, and55 //! it is written exclusively by the local `devsite` CLI.66 77 use std::collections::HashMap;8+use std::net::SocketAddr;89 use std::path::{Path, PathBuf};910 10-use anyhow::{bail, Context, Result};11+use anyhow::{Context, Result};1112 use devsite_proto::ResourceId;1213 use ed25519_dalek::VerifyingKey;1314 use iroh::SecretKey;1415 use serde::{Deserialize, Serialize};15-use url::Url;1616 1717 1818 @@ -22,11 +22,12 @@ pub enum Visibility {
2222 Shared,2323 }2424 25-25+2626 pub struct ExposedResource {2727 pub resource_id: ResourceId,2828 pub name: String,29- pub origin: Url,29+ /// Fixed local TCP target. The peer chooses a resource, never an address.30+ pub target: SocketAddr,3031 pub visibility: Visibility,3132 }3233 @@ -34,9 +35,11 @@ pub struct ExposedResource {
3435 pub struct DaemonConfig {3536 /// Base URL of the control plane, e.g. `https://dev.site`.3637 pub server_url: Option<String>,37- /// Session token from `devsite login`, used for CLI calls and for registering38- /// this daemon's endpoint id when it starts.39- pub session_token: Option<String>,38+ /// Revocable machine credential from `devsite login`, used for CLI calls and39+ /// for registering this daemon's endpoint id when it starts. The alias reads40+ /// configs written before machine credentials were introduced.41+ 42+ pub machine_credential: Option<String>,4043 /// The control plane's capability-signing public key, pinned at login.4144 ///4245 /// Hex-encoded. A daemon that has not pinned a key cannot verify anything and refuses@@ -61,11 +64,11 @@ impl DaemonConfig {
6164 VerifyingKey::from_bytes(&bytes).context("control plane key is not a valid ed25519 key")6265 }6366 64- /// Resource id to origin. Built fresh on each use so a config reload takes effect.65- pub fn origins(&self) -> HashMap<ResourceId, Url> {67+ /// Resource id to local target. Built fresh on each use so a config reload takes effect.68+ pub fn targets(&self) -> HashMap<ResourceId, SocketAddr> {6669 self.resources6770 .iter()68- .map(|r| (r.resource_id, r.origin.clone()))71+ .map(|r| (r.resource_id, r.target))6972 .collect()7073 }7174 }@@ -122,7 +125,8 @@ impl Paths {
122125 pub fn load_or_create_identity(&self) -> Result<SecretKey> {123126 let path = self.identity();124127 if path.exists() {125- let raw = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;128+ let raw =129+ std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;126130 let bytes: [u8; 32] = raw127131 .as_slice()128132 .try_into()@@ -155,82 +159,25 @@ fn write_private(path: &Path, contents: &[u8]) -> Result<()> {
155159 .open(path)156160 .with_context(|| format!("opening {}", path.display()))?;157161 file.write_all(contents)?;158- return Ok(());162+ Ok(())159163 }160164 161165 {162- std::fs::write(path, contents)163- .with_context(|| format!("writing {}", path.display()))?;166+ std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;164167 Ok(())165168 }166169 }167170 168-/// Reject origins the daemon should never proxy to.169-///170-/// The point of dev.site is reaching services that are *not* publicly routable. Allowing171-/// an arbitrary public origin would turn a daemon into a traffic launderer for its owner's172-/// IP, so exposures are limited to loopback and private/tailnet ranges.173-pub fn validate_origin(origin: &Url) -> Result<()> {174- match origin.scheme() {175- "http" | "https" => {}176- other => bail!("unsupported scheme `{other}` — expected http or https"),177- }178- let host = origin.host_str().context("origin has no host")?;179-180- if host == "localhost" || host.ends_with(".localhost") {181- return Ok(());182- }183- if let Ok(ip) = host.parse::<std::net::IpAddr>() {184- if ip.is_loopback() || is_private(&ip) {185- return Ok(());186- }187- bail!("{host} is a public address; dev.site only exposes local services");188- }189- // Tailnet names resolve to CGNAT space and are the documented remote case.190- if host.ends_with(".ts.net") {191- return Ok(());192- }193- bail!("{host} is not a local or tailnet address")194-}195-196-fn is_private(ip: &std::net::IpAddr) -> bool {197- match ip {198- std::net::IpAddr::V4(v4) => {199- // 100.64.0.0/10 is CGNAT, which is what Tailscale hands out.200- v4.is_private() || v4.is_link_local() || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))201- }202- std::net::IpAddr::V6(v6) => v6.is_unique_local() || v6.is_unicast_link_local(),203- }204-}205-206171 207172 mod tests {208173 use super::*;209174 210- fn url(s: &str) -> Url {211- Url::parse(s).unwrap()212- }213-214175 215- fn accepts_local_origins() {216- for good in [217- "http://127.0.0.1:4101",218- "http://localhost:8080",219- "http://192.168.1.10:3000",220- "http://100.101.102.103:80",221- "https://hermes.tailbe516a.ts.net/chat",222- ] {223- validate_origin(&url(good)).unwrap_or_else(|e| panic!("{good} rejected: {e}"));224- }225- }226-227- 228- fn refuses_to_expose_the_public_internet() {229- for bad in ["http://93.184.216.34", "https://example.com", "ftp://127.0.0.1"] {230- assert!(231- validate_origin(&url(bad)).is_err(),232- "{bad} should have been rejected"233- );234- }176+ fn reads_pre_machine_credential_configs() {177+ let config: DaemonConfig = serde_json::from_str(178+ r#"{"session_token":"old-token","control_plane_key":null,"resources":[]}"#,179+ )180+ .unwrap();181+ assert_eq!(config.machine_credential.as_deref(), Some("old-token"));235182 }236183 }@@ -5,7 +5,6 @@
55 //! the control plane never touches this traffic.66 77 pub mod config;8-pub mod upstream;98 pub mod verify;109 1110 use std::sync::Arc;@@ -18,9 +17,11 @@ use devsite_proto::ALPN;
1817 use ed25519_dalek::VerifyingKey;1918 use iroh::endpoint::Connection;2019 use iroh::{Endpoint, SecretKey};21-use tokio::sync::Mutex;20+use tokio::io::{copy, AsyncWriteExt};21+use tokio::net::TcpStream;22+use tokio::sync::{Mutex, RwLock};2223 23-use crate::config::DaemonConfig;24+use crate::config::{DaemonConfig, ExposedResource};2425 use crate::verify::{Denied, ReplayGuard};2526 2627 pub fn now_secs() -> u64 {@@ -32,8 +33,7 @@ pub fn now_secs() -> u64 {
3233 3334 pub struct Daemon {3435 endpoint: Endpoint,35- http: reqwest::Client,36- config: DaemonConfig,36+ config: RwLock<DaemonConfig>,3737 control_plane_key: VerifyingKey,3838 replay: Arc<Mutex<ReplayGuard>>,3939 }@@ -54,15 +54,9 @@ impl Daemon {
5454 .context("binding daemon endpoint")?;5555 endpoint.online().await;5656 57- let http = reqwest::Client::builder()58- .redirect(reqwest::redirect::Policy::none())59- .build()60- .context("building upstream http client")?;61-6257 Ok(Self {6358 endpoint,64- http,65- config,59+ config: RwLock::new(config),6660 control_plane_key,6761 replay: Arc::new(Mutex::new(ReplayGuard::default())),6862 })@@ -76,10 +70,30 @@ impl Daemon {
7670 *self.endpoint.id().as_bytes()7771 }7872 73+ /// Stop accepting new peers and close the Iroh endpoint cleanly. Service74+ /// managers do not need a platform-specific integration; terminating the75+ /// foreground command is still the whole lifecycle contract.76+ pub async fn close(&self) {77+ self.endpoint.close().await;78+ }79+80+ /// Replace only the resource map while the endpoint keeps its identity and81+ /// open connections. This is intentionally platform-neutral: service82+ /// managers only need to keep `devsite daemon run` alive.83+ pub async fn replace_resources(&self, resources: Vec<ExposedResource>) -> bool {84+ let mut config = self.config.write().await;85+ if config.resources == resources {86+ return false;87+ }88+ config.resources = resources;89+ true90+ }91+7992 /// Accept connections until the endpoint closes.8093 pub async fn serve(self: Arc<Self>) -> Result<()> {94+ let resources = self.config.read().await.resources.len();8195 tracing::info!(82- resources = self.config.resources.len(),96+ resources,8397 "daemon serving; every request must present a valid capability"8498 );8599 @@ -103,7 +117,7 @@ impl Daemon {
103117 /// rather than as anything obviously wrong here.104118 async fn handle_connection(self: Arc<Self>, connection: Connection) {105119 // 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.120+ // and the capability's client binding is checked against it.107121 let peer: KeyBytes = *connection.remote_id().as_bytes();108122 109123 loop {@@ -140,29 +154,22 @@ impl Daemon {
140154 .await141155 .context("reading request frame")?;142156 143- let response = match wire::decode::<Request>(&payload) {144- Ok(Request::Http(request)) => self.serve_http(request, peer).await,157+ let request = match wire::decode::<Request>(&payload) {158+ Ok(request) => request,145159 Err(err) => {146160 tracing::debug!("malformed frame from {}: {err:#}", hex(peer));147- Response::Error {148- code: ErrorCode::BadRequest,149- message: "malformed request".to_string(),150- }161+ return send_response(162+ &mut send,163+ Response::Error {164+ code: ErrorCode::BadRequest,165+ message: "malformed request".to_string(),166+ },167+ )168+ .await;151169 }152170 };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();171+ let Request::Connect(request) = request;172+ let targets = self.config.read().await.targets();166173 let my_endpoint = self.endpoint_key();167174 let now = now_secs();168175 @@ -170,45 +177,89 @@ impl Daemon {
170177 let mut replay = self.replay.lock().await;171178 verify::authorize(172179 &request.capability,173- request.method,174180 &self.control_plane_key,175181 &my_endpoint,176182 peer,177- &origins,183+ &targets,178184 &mut replay,179185 now,180186 )181187 };182188 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- }189+ let authorized = match authorized {190+ Ok(authorized) => authorized,193191 Err(denied) => {194192 // Logged with detail locally, reported to the peer as a bare "denied".195193 tracing::warn!(peer = %hex(peer), reason = ?denied, "denied request");196194 debug_assert!(matches!(197195 denied,198- Denied::Capability199- | Denied::UnknownResource200- | Denied::MethodNotPermitted201- | Denied::Replayed196+ Denied::Capability | Denied::UnknownResource | Denied::Replayed202197 ));203- Response::Error {204- code: ErrorCode::Denied,205- message: "denied".to_string(),206- }198+ return send_response(199+ &mut send,200+ Response::Error {201+ code: ErrorCode::Denied,202+ message: "denied".to_string(),203+ },204+ )205+ .await;207206 }208- }207+ };208+209+ let upstream = match TcpStream::connect(authorized.target).await {210+ Ok(stream) => stream,211+ Err(err) => {212+ tracing::warn!(target = %authorized.target, "local service did not accept TCP: {err}");213+ return send_response(214+ &mut send,215+ Response::Error {216+ code: ErrorCode::UpstreamUnavailable,217+ message: "local service did not accept a connection".to_string(),218+ },219+ )220+ .await;221+ }222+ };223+224+ tracing::info!(225+ viewer = %authorized.claims.viewer,226+ resource = %authorized.claims.resource,227+ target = %authorized.target,228+ "opened service stream"229+ );230+ send.write_all(&wire::encode(&Response::Connected)?)231+ .await232+ .context("writing connect response")?;233+234+ let (mut upstream_read, mut upstream_write) = upstream.into_split();235+ let client_to_service = async {236+ copy(&mut recv, &mut upstream_write)237+ .await238+ .context("forwarding client bytes")?;239+ upstream_write.shutdown().await.ok();240+ Ok::<_, anyhow::Error>(())241+ };242+ let service_to_client = async {243+ copy(&mut upstream_read, &mut send)244+ .await245+ .context("forwarding service bytes")?;246+ send.finish().context("finishing service stream")?;247+ Ok::<_, anyhow::Error>(())248+ };249+ tokio::try_join!(client_to_service, service_to_client)?;250+ Ok(())209251 }210252 }211253 254+async fn send_response(send: &mut iroh::endpoint::SendStream, response: Response) -> Result<()> {255+ send.write_all(&wire::encode(&response)?)256+ .await257+ .context("writing response frame")?;258+ send.finish().context("finishing response stream")?;259+ send.stopped().await.ok();260+ Ok(())261+}262+212263 fn hex(bytes: &KeyBytes) -> String {213264 data_encoding::HEXLOWER.encode(bytes)214265 }@@ -1,162 +0,0 @@
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-117-mod tests {118- use super::*;119-120- fn origin() -> Url {121- Url::parse("http://127.0.0.1:4101").unwrap()122- }123-124- 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- 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- 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- 158- fn requires_an_absolute_path() {159- assert!(upstream_url(&origin(), "chat").is_err());160- assert!(upstream_url(&origin(), "").is_err());161- }162-}@@ -5,18 +5,17 @@
55 //! "not shared with you" could enumerate a profile's private services.66 77 use std::collections::HashMap;8+use std::net::SocketAddr;89 910 use devsite_proto::capability::{CapabilityClaims, KeyBytes, Permission, SignedCapability};10-use devsite_proto::wire::Method;1111 use devsite_proto::ResourceId;1212 use ed25519_dalek::VerifyingKey;13-use url::Url;1413 15-/// A request that passed every check, carrying the origin it resolved to.14+/// A request that passed every check, carrying the local target it resolved to.1615 1716 pub struct Authorized {1817 pub claims: CapabilityClaims,19- pub origin: Url,18+ pub target: SocketAddr,2019 }2120 2221 @@ -25,8 +24,6 @@ pub enum Denied {
2524 Capability,2625 /// Well-formed and correctly signed, but names a resource this daemon does not serve.2726 UnknownResource,28- /// The grant does not cover this method.29- MethodNotPermitted,3027 /// This exact capability has already been used.3128 Replayed,3229 }@@ -66,32 +63,27 @@ impl ReplayGuard {
6663 /// `Connection::remote_id()` — and never from anything inside the request frame.6764 pub fn authorize(6865 capability: &SignedCapability,69- method: Method,7066 control_plane_key: &VerifyingKey,7167 my_endpoint: &KeyBytes,7268 peer: &KeyBytes,73- origins: &HashMap<ResourceId, Url>,69+ targets: &HashMap<ResourceId, SocketAddr>,7470 replay: &mut ReplayGuard,7571 now: u64,7672 ) -> Result<Authorized, Denied> {77- // Cryptography first: signature, audience, browser binding, expiry. Until this passes73+ // Cryptography first: signature, audience, client binding, expiry. Until this passes7874 // the claims are just bytes a stranger sent us.7975 let claims = capability8076 .verify_for(control_plane_key, my_endpoint, peer, now)8177 .map_err(|_| Denied::Capability)?;8278 83- match claims.permission {84- Permission::HttpRead => {85- if !matches!(method, Method::Get | Method::Head) {86- return Err(Denied::MethodNotPermitted);87- }88- }79+ if claims.permission != Permission::TcpConnect {80+ return Err(Denied::Capability);8981 }9082 91- let origin = origins83+ let target = targets9284 .get(&claims.resource)9385 .ok_or(Denied::UnknownResource)?94- .clone();86+ .to_owned();9587 9688 // Last, because consuming the nonce has a side effect: a request that would have been9789 // refused anyway must not burn a legitimate capability.@@ -99,7 +91,7 @@ pub fn authorize(
9991 return Err(Denied::Replayed);10092 }10193 102- Ok(Authorized { claims, origin })94+ Ok(Authorized { claims, target })10395 }10496 10597 @@ -118,8 +110,8 @@ mod tests {
118110 ResourceId::from_bytes([2; 16])119111 }120112 121- fn origins() -> HashMap<ResourceId, Url> {122- HashMap::from([(resource(), Url::parse("http://127.0.0.1:4101").unwrap())])113+ fn targets() -> HashMap<ResourceId, SocketAddr> {114+ HashMap::from([(resource(), "127.0.0.1:4101".parse().unwrap())])123115 }124116 125117 fn claims_with(nonce: [u8; 16], res: ResourceId) -> CapabilityClaims {@@ -128,8 +120,8 @@ mod tests {
128120 viewer: AccountId::from_bytes([1; 16]),129121 resource: res,130122 audience: DAEMON,131- browser_key: BROWSER,132- permission: Permission::HttpRead,123+ client_key: BROWSER,124+ permission: Permission::TcpConnect,133125 issued_at: NOW,134126 expires_at: NOW + DEFAULT_LIFETIME_SECS,135127 nonce,@@ -145,11 +137,10 @@ mod tests {
145137 let cap = SignedCapability::sign(claims, key).unwrap();146138 authorize(147139 &cap,148- Method::Get,149140 &key.verifying_key(),150141 &DAEMON,151142 peer,152- &origins(),143+ &targets(),153144 replay,154145 NOW,155146 )@@ -159,16 +150,27 @@ mod tests {
159150 fn allows_a_valid_request() {160151 let key = SigningKey::from_bytes(&[9; 32]);161152 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/");153+ let ok = run(154+ &key,155+ &claims_with([1; 16], resource()),156+ &BROWSER,157+ &mut replay,158+ )159+ .unwrap();160+ assert_eq!(ok.target, "127.0.0.1:4101".parse().unwrap());164161 }165162 166163 167- fn denies_a_capability_bound_to_another_browser() {164+ fn denies_a_capability_bound_to_another_client() {168165 let key = SigningKey::from_bytes(&[9; 32]);169166 let mut replay = ReplayGuard::default();170- let denied = run(&key, &claims_with([1; 16], resource()), &[99; 32], &mut replay)171- .unwrap_err();167+ let denied = run(168+ &key,169+ &claims_with([1; 16], resource()),170+ &[99; 32],171+ &mut replay,172+ )173+ .unwrap_err();172174 assert_eq!(denied, Denied::Capability);173175 }174176 @@ -179,8 +181,7 @@ mod tests {
179181 let key = SigningKey::from_bytes(&[9; 32]);180182 let mut replay = ReplayGuard::default();181183 let stranger = ResourceId::from_bytes([44; 16]);182- let denied =183- run(&key, &claims_with([1; 16], stranger), &BROWSER, &mut replay).unwrap_err();184+ let denied = run(&key, &claims_with([1; 16], stranger), &BROWSER, &mut replay).unwrap_err();184185 assert_eq!(denied, Denied::UnknownResource);185186 }186187 @@ -205,7 +206,10 @@ mod tests {
205206 let claims = claims_with([1; 16], resource());206207 207208 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!(210+ replay.is_empty(),211+ "a failed attempt must not record a nonce"212+ );209213 assert!(run(&key, &claims, &BROWSER, &mut replay).is_ok());210214 }211215 @@ -1,12 +1,12 @@
1-//! The authorization matrix, exercised over real Iroh against a real local service.1+//! The authorization matrix, exercised over real Iroh against real local TCP services.22 //!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 the3+//! These run the same `devsite-client` code the CLI runs. Capabilities are minted directly4+//! by a test issuer rather than by the55 //! control plane, which keeps the tests focused on what the *daemon* enforces — the66 //! control plane's own "may this viewer have a capability at all" rules are covered by the77 //! policy tests in devsite-server.88 //!9-//! Negative cases assert `FetchError::Denied` specifically, never merely "an error". A9+//! Negative cases assert `ConnectError::Denied` specifically, never merely "an error". A1010 //! relay timeout is also an error, and a test that accepted one would report a working1111 //! authorization check on a daemon that was simply unreachable.1212 //!@@ -16,7 +16,7 @@
1616 1717 use std::sync::Arc;1818 19-use devsite_client::{FetchError, ViewerEndpoint};19+use devsite_client::{ConnectError, ServiceStream, ViewerEndpoint};2020 use devsite_daemon::config::{DaemonConfig, ExposedResource, Visibility};2121 use devsite_daemon::Daemon;2222 use devsite_proto::capability::{@@ -27,31 +27,27 @@ use ed25519_dalek::SigningKey;
2727 use iroh::{EndpointAddr, RelayUrl, SecretKey};2828 use tokio::io::{AsyncReadExt, AsyncWriteExt};2929 use tokio::net::TcpListener;30-use url::Url;3130 32-const HERMES_BODY: &str = "<h1>Hermes</h1>";33-const AGENT_BODY: &str = "<h1>Agent</h1>";31+const HERMES: &str = "hermes";32+const AGENT: &str = "agent";3433 35-async fn spawn_service(body: &'static str) -> Url {34+async fn spawn_service(name: &'static str) -> std::net::SocketAddr {3635 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();37- let port = listener.local_addr().unwrap().port();36+ let target = listener.local_addr().unwrap();3837 tokio::spawn(async move {3938 loop {4039 let Ok((mut stream, _)) = listener.accept().await else {4140 return;4241 };4342 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- );43+ let mut buf = Vec::new();44+ let _ = stream.read_to_end(&mut buf).await;45+ let response = format!("{name}:{}", String::from_utf8_lossy(&buf));5046 let _ = stream.write_all(response.as_bytes()).await;5147 });5248 }5349 });54- Url::parse(&format!("http://127.0.0.1:{port}")).unwrap()50+ target5551 }5652 5753 struct Harness {@@ -64,7 +60,7 @@ struct Harness {
6460 unknown: ResourceId,6561 /// The legitimate viewer.6662 viewer: ViewerEndpoint,67- /// A second viewer, for anything about one browser using another's grant.63+ /// A second viewer, for anything about one client using another's grant.6864 mallory: ViewerEndpoint,6965 }7066 @@ -76,7 +72,7 @@ impl Harness {
7672 7773 let config = DaemonConfig {7874 server_url: Some("https://dev.site".into()),79- session_token: Some("test".into()),75+ machine_credential: Some("test".into()),8076 control_plane_key: Some(8177 data_encoding::HEXLOWER.encode(signing_key.verifying_key().as_bytes()),8278 ),@@ -84,13 +80,13 @@ impl Harness {
8480 ExposedResource {8581 resource_id: hermes,8682 name: "Hermes".into(),87- origin: spawn_service(HERMES_BODY).await,83+ target: spawn_service(HERMES).await,8884 visibility: Visibility::Private,8985 },9086 ExposedResource {9187 resource_id: agent,9288 name: "Agent".into(),93- origin: spawn_service(AGENT_BODY).await,89+ target: spawn_service(AGENT).await,9490 visibility: Visibility::Shared,9591 },9692 ],@@ -133,7 +129,7 @@ impl Harness {
133129 key: &SigningKey,134130 resource: ResourceId,135131 audience: KeyBytes,136- browser_key: KeyBytes,132+ client_key: KeyBytes,137133 issued_at: u64,138134 expires_at: u64,139135 ) -> SignedCapability {@@ -145,8 +141,8 @@ impl Harness {
145141 viewer: AccountId::generate(),146142 resource,147143 audience,148- browser_key,149- permission: Permission::HttpRead,144+ client_key,145+ permission: Permission::TcpConnect,150146 issued_at,151147 expires_at,152148 nonce,@@ -169,37 +165,46 @@ impl Harness {
169165 )170166 }171167 172- async fn fetch_as(168+ async fn connect_as(173169 &self,174170 who: &ViewerEndpoint,175171 capability: SignedCapability,176- path: &str,177- ) -> Result<String, FetchError> {172+ input: &str,173+ ) -> Result<String, ConnectError> {178174 // The relay is pinned rather than looked up. In the browser a bare179175 // endpoint id is enough — the daemon publishes its address and the viewer180176 // resolves it — but that path depends on a third-party lookup service,181177 // and these tests are about what the daemon enforces, not about how it182178 // was found. Handing the address over directly keeps a denial a denial183179 // and never a lookup that was slow.184- let addr = EndpointAddr::from(self.daemon.endpoint().id()).with_relay_url(self.relay.clone());185- who.fetch(addr, capability, path)180+ let addr =181+ EndpointAddr::from(self.daemon.endpoint().id()).with_relay_url(self.relay.clone());182+ let ServiceStream { mut send, mut recv } = who.connect(addr, capability).await?;183+ send.write_all(input.as_bytes())186184 .await187- .map(|page| page.text())185+ .map_err(|err| ConnectError::Transport(err.into()))?;186+ send.finish()187+ .map_err(|err| ConnectError::Transport(err.into()))?;188+ let output = recv189+ .read_to_end(1024 * 1024)190+ .await191+ .map_err(|err| ConnectError::Transport(err.into()))?;192+ Ok(String::from_utf8_lossy(&output).into_owned())188193 }189194 190- async fn fetch(&self, capability: SignedCapability) -> Result<String, FetchError> {191- self.fetch_as(&self.viewer, capability, "/").await195+ async fn connect(&self, capability: SignedCapability) -> Result<String, ConnectError> {196+ self.connect_as(&self.viewer, capability, "hello").await192197 }193198 194199 /// Assert the daemon reached a verdict and refused, rather than being unreachable.195200 async fn assert_denied(&self, capability: SignedCapability, case: &str) {196- match self.fetch(capability).await {197- Err(FetchError::Denied) => {}198- Err(FetchError::Transport(err)) => {201+ match self.connect(capability).await {202+ Err(ConnectError::Denied) => {}203+ Err(ConnectError::Transport(err)) => {199204 panic!("{case}: never reached a verdict — {err:#}")200205 }201206 Err(other) => panic!("{case}: expected a denial, got {other}"),202- Ok(body) => panic!("{case}: expected a denial, but the page was served: {body}"),207+ Ok(body) => panic!("{case}: expected a denial, but the service answered: {body}"),203208 }204209 }205210 }@@ -214,38 +219,36 @@ impl Harness {
214219 async fn authorization_matrix() {215220 let h = Harness::start().await;216221 217- valid_capability_fetches_the_page(&h).await;222+ valid_capability_opens_a_stream(&h).await;218223 each_resource_maps_to_its_own_service(&h).await;219- capability_bound_to_another_browser_is_refused(&h).await;224+ capability_bound_to_another_client_is_refused(&h).await;220225 capabilities_that_fail_verification_are_refused(&h).await;221226 a_capability_cannot_be_redeemed_twice(&h).await;222227 denials_reveal_nothing_about_why(&h).await;223- the_daemon_cannot_be_turned_into_an_open_proxy(&h).await;224228 }225229 226-async fn valid_capability_fetches_the_page(h: &Harness) {227- let page = h228- .fetch(h.valid(h.hermes))230+async fn valid_capability_opens_a_stream(h: &Harness) {231+ let response = h232+ .connect(h.valid(h.hermes))229233 .await230234 .expect("a valid capability should be honoured");231- assert!(page.contains(HERMES_BODY), "got: {page}");235+ assert_eq!(response, "hermes:hello");232236 }233237 234238 async fn each_resource_maps_to_its_own_service(h: &Harness) {235- // Proves the daemon routes by resource id rather than serving one default origin —239+ // Proves the daemon routes by resource id rather than serving one default target —236240 // otherwise "Bob can reach Agent" would quietly also mean "Bob can reach Hermes".237- let page = h.fetch(h.valid(h.agent)).await.unwrap();238- assert!(page.contains(AGENT_BODY), "got: {page}");239- assert!(!page.contains(HERMES_BODY));241+ let response = h.connect(h.valid(h.agent)).await.unwrap();242+ assert_eq!(response, "agent:hello");240243 }241244 242-async fn capability_bound_to_another_browser_is_refused(h: &Harness) {245+async fn capability_bound_to_another_client_is_refused(h: &Harness) {243246 // The core claim: a leaked or forwarded capability is useless without the private key244247 // it was bound to. Mallory presents the viewer's capability from her own endpoint.245248 let stolen = h.valid(h.agent);246- match h.fetch_as(&h.mallory, stolen, "/").await {247- Err(FetchError::Denied) => {}248- Err(FetchError::Transport(err)) => panic!("never reached a verdict — {err:#}"),249+ match h.connect_as(&h.mallory, stolen, "stolen").await {250+ Err(ConnectError::Denied) => {}251+ Err(ConnectError::Transport(err)) => panic!("never reached a verdict — {err:#}"),249252 Err(other) => panic!("expected a denial, got {other}"),250253 Ok(body) => panic!("a rebound capability was honoured: {body}"),251254 }@@ -271,25 +274,40 @@ async fn capabilities_that_fail_verification_are_refused(h: &Harness) {
271274 272275 // Expired.273276 h.assert_denied(274- h.mint(&h.signing_key, h.hermes, h.daemon_key, mine, now - 600, now - 300),277+ h.mint(278+ &h.signing_key,279+ h.hermes,280+ h.daemon_key,281+ mine,282+ now - 600,283+ now - 300,284+ ),275285 "expired",276286 )277287 .await;278288 279289 // Addressed to a different daemon.280290 h.assert_denied(281- h.mint(&h.signing_key, h.hermes, [99; 32], mine, now, now + DEFAULT_LIFETIME_SECS),291+ h.mint(292+ &h.signing_key,293+ h.hermes,294+ [99; 32],295+ mine,296+ now,297+ now + DEFAULT_LIFETIME_SECS,298+ ),282299 "wrong audience",283300 )284301 .await;285302 286303 // Correctly signed and bound, but names a resource this daemon does not serve.287- h.assert_denied(h.valid(h.unknown), "unknown resource").await;304+ h.assert_denied(h.valid(h.unknown), "unknown resource")305+ .await;288306 }289307 290308 async fn a_capability_cannot_be_redeemed_twice(h: &Harness) {291309 let capability = h.valid(h.hermes);292- assert!(h.fetch(capability.clone()).await.is_ok());310+ assert!(h.connect(capability.clone()).await.is_ok());293311 h.assert_denied(capability, "replayed capability").await;294312 }295313 @@ -299,9 +317,9 @@ async fn denials_reveal_nothing_about_why(h: &Harness) {
299317 let now = h.now();300318 let mine = *h.viewer.endpoint_id().as_bytes();301319 302- let unknown = h.fetch(h.valid(h.unknown)).await.unwrap_err();320+ let unknown = h.connect(h.valid(h.unknown)).await.unwrap_err();303321 let forged = h304- .fetch(h.mint(322+ .connect(h.mint(305323 &SigningKey::from_bytes(&[7; 32]),306324 h.hermes,307325 h.daemon_key,@@ -312,8 +330,8 @@ async fn denials_reveal_nothing_about_why(h: &Harness) {
312330 .await313331 .unwrap_err();314332 315- assert!(matches!(unknown, FetchError::Denied));316- assert!(matches!(forged, FetchError::Denied));333+ assert!(matches!(unknown, ConnectError::Denied));334+ assert!(matches!(forged, ConnectError::Denied));317335 assert_eq!(318336 unknown.to_string(),319337 forged.to_string(),@@ -321,31 +339,13 @@ async fn denials_reveal_nothing_about_why(h: &Harness) {
321339 );322340 }323341 324-async fn the_daemon_cannot_be_turned_into_an_open_proxy(h: &Harness) {325- // The peer only ever supplies a path. These are the paths that would escape the326- // configured origin if they were joined onto it naively.327- for hostile in [328- "http://example.com/",329- "//example.com/",330- "/../../etc/passwd",331- "/x\r\nHost: example.com",332- ] {333- match h.fetch_as(&h.viewer, h.valid(h.hermes), hostile).await {334- // A bad path is a client error, distinct from an authorization denial.335- Err(FetchError::Rejected(_)) => {}336- Err(FetchError::Transport(err)) => {337- panic!("{hostile:?}: never reached a verdict — {err:#}")338- }339- Err(other) => panic!("{hostile:?}: unexpected {other}"),340- Ok(body) => panic!("{hostile:?} was proxied: {body}"),341- }342- }343-}344-345342 346343 fn resource_ids_do_not_collide() {347344 let mut seen = std::collections::HashSet::new();348345 for _ in 0..10_000 {349- assert!(seen.insert(ResourceId::generate()), "generated a duplicate id");346+ assert!(347+ seen.insert(ResourceId::generate()),348+ "generated a duplicate id"349+ );350350 }351351 }@@ -1,61 +0,0 @@
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-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-}@@ -1,7 +1,7 @@
11 //! Short-lived, signed grants issued by the control plane and verified by a daemon.22 //!33 //! 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, one4+//! is deliberately narrow: one viewer, one resource, one daemon, one client key, one55 //! permission, a few minutes.66 //!77 //! Public keys are carried as raw bytes rather than iroh types so this crate stays free of@@ -25,8 +25,8 @@ const MAX_CLOCK_SKEW_SECS: u64 = 30;
2525 2626 2727 pub enum Permission {28- /// GET/HEAD only. The daemon refuses anything that could mutate the local service.29- HttpRead,28+ /// Open one bidirectional byte stream to the resource's configured TCP target.29+ TcpConnect,3030 }3131 3232 @@ -38,10 +38,10 @@ pub struct CapabilityClaims {
3838 /// The daemon this grant is addressed to. A daemon rejects capabilities minted for a3939 /// different audience, so one daemon cannot replay a grant against another.4040 pub audience: KeyBytes,41- /// The browser endpoint key this grant is bound to. The daemon checks it against the41+ /// The client endpoint key this grant is bound to. The daemon checks it against the4242 /// authenticated peer of the connection, which is what makes a stolen capability4343 /// useless without the matching private key.44- pub browser_key: KeyBytes,44+ pub client_key: KeyBytes,4545 pub permission: Permission,4646 pub issued_at: u64,4747 pub expires_at: u64,@@ -74,8 +74,8 @@ pub enum CapabilityError {
7474 NotYetValid,7575 7676 WrongAudience,77- 78- WrongBrowserKey,77+ 78+ WrongClientKey,7979 }8080 8181 impl SignedCapability {@@ -91,7 +91,7 @@ impl SignedCapability {
9191 /// Verify the signature and return the claims.9292 ///9393 /// 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.94+ /// expiry, client binding — live in [`Self::verify_for`], which callers should prefer.9595 pub fn verify(&self, key: &VerifyingKey) -> Result<CapabilityClaims, CapabilityError> {9696 let signature: [u8; 64] = self9797 .signature@@ -119,10 +119,10 @@ impl SignedCapability {
119119 if &claims.audience != audience {120120 return Err(CapabilityError::WrongAudience);121121 }122- // The check the whole design rests on: the capability names a browser key, and the122+ // The check the whole design rests on: the capability names a client key, and the123123 // connection proves possession of one. They must be the same key.124- if &claims.browser_key != peer {125- return Err(CapabilityError::WrongBrowserKey);124+ if &claims.client_key != peer {125+ return Err(CapabilityError::WrongClientKey);126126 }127127 if now > claims.expires_at {128128 return Err(CapabilityError::Expired);@@ -152,14 +152,14 @@ mod tests {
152152 SigningKey::from_bytes(&[seed; 32])153153 }154154 155- fn claims(audience: KeyBytes, browser_key: KeyBytes) -> CapabilityClaims {155+ fn claims(audience: KeyBytes, client_key: KeyBytes) -> CapabilityClaims {156156 CapabilityClaims {157157 issuer: "https://dev.site".to_string(),158158 viewer: AccountId::from_bytes([1; 16]),159159 resource: ResourceId::from_bytes([2; 16]),160160 audience,161- browser_key,162- permission: Permission::HttpRead,161+ client_key,162+ permission: Permission::TcpConnect,163163 issued_at: NOW,164164 expires_at: NOW + DEFAULT_LIFETIME_SECS,165165 nonce: [3; 16],@@ -173,7 +173,7 @@ mod tests {
173173 let verified = cap174174 .verify_for(&key.verifying_key(), &[7; 32], &[8; 32], NOW + 1)175175 .unwrap();176- assert_eq!(verified.permission, Permission::HttpRead);176+ assert_eq!(verified.permission, Permission::TcpConnect);177177 }178178 179179 @@ -198,14 +198,14 @@ mod tests {
198198 }199199 200200 201- fn rejects_use_from_a_different_browser_key() {201+ fn rejects_use_from_a_different_client_key() {202202 // Bob hands his capability to someone else, or it leaks from the network. The203203 // thief connects with their own endpoint key, and the binding fails.204204 let key = signing_key(9);205205 let cap = SignedCapability::sign(&claims([7; 32], [8; 32]), &key).unwrap();206206 assert_eq!(207207 cap.verify_for(&key.verifying_key(), &[7; 32], &[99; 32], NOW),208- Err(CapabilityError::WrongBrowserKey)208+ Err(CapabilityError::WrongClientKey)209209 );210210 }211211 @@ -134,7 +134,11 @@ mod tests {
134134 fn postcard_round_trip_is_compact() {135135 let id = ResourceId::from_bytes([9u8; 16]);136136 let bytes = postcard::to_allocvec(&id).unwrap();137- assert_eq!(bytes.len(), 16, "ids must not be length-prefixed on the wire");137+ assert_eq!(138+ bytes.len(),139+ 16,140+ "ids must not be length-prefixed on the wire"141+ );138142 assert_eq!(postcard::from_bytes::<ResourceId>(&bytes).unwrap(), id);139143 }140144 }@@ -1,5 +1,5 @@
11 //! Types shared by every dev.site component: the CLI, the daemon, the control plane, and2-//! the browser WASM endpoint. No I/O lives here, so it compiles unchanged for wasm32.2+//! native clients. No I/O lives here.33 44 pub mod capability;55 pub mod id;@@ -7,8 +7,8 @@ pub mod wire;
77 88 pub use capability::{CapabilityClaims, CapabilityError, Permission, SignedCapability};99 pub use id::{AccountId, IdParseError, ResourceId};10-pub use wire::{ErrorCode, HttpRequest, Method, Request, Response};10+pub use wire::{ConnectRequest, ErrorCode, Request, Response};1111 12-/// ALPN for the dev.site proxy protocol. Bumping the trailing version retires every13-/// previously issued capability shape, since peers negotiate on this string.14-pub const ALPN: &[u8] = b"devsite/http/0";12+/// ALPN for the dev.site stream protocol. Version 1 replaced the early one-document13+/// HTTP fetch with an authorized bidirectional TCP stream.14+pub const ALPN: &[u8] = b"devsite/tcp/1";@@ -1,7 +1,7 @@
11 //! Frames exchanged over an Iroh bidirectional stream, and their codec.22 //!3-//! One request frame and one response frame per stream. Frames are `postcard`-encoded and4-//! prefixed with a u32 little-endian length.3+//! A stream begins with one request frame and one response frame. After `Connected`, the4+//! rest of the Iroh stream is the service's uninterpreted byte stream.55 66 use serde::{Deserialize, Serialize};77 @@ -13,46 +13,20 @@ pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
1313 1414 1515 pub enum Request {16- Http(HttpRequest),16+ Connect(ConnectRequest),1717 }1818 1919 20-pub struct HttpRequest {21- /// The grant authorizing this request. Required, not optional: a daemon must never20+pub struct ConnectRequest {21+ /// The grant authorizing this connection. Required, not optional: a daemon must never2222 /// have a code path where an absent capability means "allow".2323 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-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- }4324 }4425 4526 4627 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- },28+ Connected,29+ Error { code: ErrorCode, message: String },5630 }5731 5832 /// Deliberately coarse. A peer that fails authorization learns only "denied" — never@@ -63,7 +37,6 @@ pub enum ErrorCode {
6337 Denied,6438 BadRequest,6539 UpstreamUnavailable,66- UpstreamTooLarge,6740 }6841 6942 @@ -116,8 +89,8 @@ mod tests {
11689 viewer: AccountId::from_bytes([1; 16]),11790 resource: ResourceId::from_bytes([2; 16]),11891 audience: [7; 32],119- browser_key: [8; 32],120- permission: Permission::HttpRead,92+ client_key: [8; 32],93+ permission: Permission::TcpConnect,12194 issued_at: 0,12295 expires_at: 300,12396 nonce: [3; 16],@@ -127,19 +100,19 @@ mod tests {
127100 128101 129102 fn frames_round_trip() {130- let request = Request::Http(HttpRequest {103+ let request = Request::Connect(ConnectRequest {131104 capability: sample_capability(),132- method: Method::Get,133- path: "/".to_string(),134105 });135106 let encoded = encode(&request).unwrap();136107 let size = frame_len(encoded[..4].try_into().unwrap()).unwrap();137108 assert_eq!(size, encoded.len() - 4);138109 139110 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);111+ let Request::Connect(connect) = decoded;112+ assert_eq!(113+ connect.capability.to_bytes(),114+ sample_capability().to_bytes()115+ );143116 }144117 145118 @@ -19,10 +19,10 @@ serde.workspace = true
1919 serde_json = "1"2020 jsonwebtoken = "9"2121 reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }22+url = "2"2223 ed25519-dalek = { version = "2", features = ["rand_core"] }2324 rand = "0.8"2425 sha2 = "0.10"2526 data-encoding.workspace = true2627 tracing.workspace = true2728 tracing-subscriber = { version = "0.3", features = ["env-filter"] }28-url = "2"@@ -1,5 +1,7 @@
11 //! HTTP surface of the control plane.22 3+use std::collections::{HashMap, HashSet};4+use std::hash::Hash;35 use std::str::FromStr;46 use std::sync::{Arc, Mutex};57 use std::time::{SystemTime, UNIX_EPOCH};@@ -7,25 +9,80 @@ use std::time::{SystemTime, UNIX_EPOCH};
79 use axum::extract::{Path, State};810 use axum::http::{header, HeaderMap, StatusCode};911 use axum::response::{IntoResponse, Response};10-use axum::routing::{get, post};12+use axum::routing::{delete, get, post, put};1113 use axum::{Json, Router};1214 use devsite_proto::capability::KeyBytes;1315 use devsite_proto::{AccountId, ResourceId};1416 use serde::{Deserialize, Serialize};1517 1618 use crate::auth::{self, ShooVerifier};17-use crate::db::{Db, ResourceKind};19+use crate::db::{Db, ResourceKind, ShareStatus};1820 use crate::issuer::Issuer;1921 use crate::policy::{can_view, Visibility};2022 use crate::theme;2123 2224 pub struct AppState {2325 pub db: Mutex<Db>,26+ pub rate_limits: Mutex<RateLimits>,2427 pub issuer: Issuer,2528 pub shoo: ShooVerifier,2629 pub public_origin: String,2730 }2831 32+33+pub enum RateClass {34+ Session,35+ Mutation,36+ Credential,37+ Capability,38+}39+40+impl RateClass {41+ fn limit(self) -> u32 {42+ match self {43+ Self::Session => 12,44+ Self::Mutation => 120,45+ Self::Credential => 20,46+ Self::Capability => 300,47+ }48+ }49+}50+51+52+pub struct RateLimits {53+ windows: HashMap<(AccountId, RateClass), RateWindow>,54+}55+56+struct RateWindow {57+ started_at: u64,58+ requests: u32,59+}60+61+impl RateLimits {62+ fn check(&mut self, account: AccountId, class: RateClass, now: u64) -> bool {63+ const WINDOW_SECS: u64 = 60;64+ // One small entry per active account/class. Expired entries from users65+ // who never return are discarded opportunistically.66+ if self.windows.len() > 4096 {67+ self.windows68+ .retain(|_, window| now.saturating_sub(window.started_at) < WINDOW_SECS);69+ }70+ let window = self.windows.entry((account, class)).or_insert(RateWindow {71+ started_at: now,72+ requests: 0,73+ });74+ if now.saturating_sub(window.started_at) >= WINDOW_SECS {75+ window.started_at = now;76+ window.requests = 0;77+ }78+ if window.requests >= class.limit() {79+ return false;80+ }81+ window.requests += 1;82+ true83+ }84+}85+2986 pub type Shared = Arc<AppState>;3087 3188 pub fn now_secs() -> u64 {@@ -47,6 +104,12 @@ impl ApiError {
47104 fn unauthorized() -> Self {48105 Self(StatusCode::UNAUTHORIZED, "sign in first".into())49106 }107+ fn too_many_requests() -> Self {108+ Self(109+ StatusCode::TOO_MANY_REQUESTS,110+ "too many requests — try again shortly".into(),111+ )112+ }50113 /// Used wherever revealing existence would leak information.51114 fn not_found() -> Self {52115 Self(StatusCode::NOT_FOUND, "not found".into())@@ -72,14 +135,23 @@ type ApiResult<T> = Result<T, ApiError>;
72135 /// Returns `None` for anonymous callers rather than erroring, because public profiles are73136 /// readable without signing in.74137 fn current_account(state: &AppState, headers: &HeaderMap) -> ApiResult<Option<AccountId>> {75- let token = bearer_token(headers).or_else(|| cookie_token(headers));76- let Some(token) = token else {138+ let now = now_secs();139+ let db = state.db.lock().unwrap();140+ if let Some(token) = bearer_token(headers) {141+ let hash = auth::hash_token(&token);142+ // Browser sessions remain accepted as bearer tokens for local operator143+ // workflows, but installed CLIs use independently revocable credentials.144+ let account = db145+ .machine_account(&hash, now)146+ .map_err(ApiError::internal)?147+ .or(db.session_account(&hash, now).map_err(ApiError::internal)?);148+ return Ok(account.map(|a| a.id));149+ }150+ let Some(token) = cookie_token(headers) else {77151 return Ok(None);78152 };79- let hash = auth::hash_token(&token);80- let db = state.db.lock().unwrap();81153 let account = db82- .session_account(&hash, now_secs())154+ .session_account(&auth::hash_token(&token), now)83155 .map_err(ApiError::internal)?;84156 Ok(account.map(|a| a.id))85157 }@@ -88,6 +160,31 @@ fn require_account(state: &AppState, headers: &HeaderMap) -> ApiResult<AccountId
88160 current_account(state, headers)?.ok_or_else(ApiError::unauthorized)89161 }90162 163+/// Dashboard-only operations require the browser's HttpOnly session cookie. A164+/// stolen machine credential can manage the profile, but cannot mint siblings165+/// or revoke the owner's remaining ways back in.166+fn require_browser_account(state: &AppState, headers: &HeaderMap) -> ApiResult<AccountId> {167+ let token = cookie_token(headers).ok_or_else(ApiError::unauthorized)?;168+ let db = state.db.lock().unwrap();169+ db.session_account(&auth::hash_token(&token), now_secs())170+ .map_err(ApiError::internal)?171+ .map(|account| account.id)172+ .ok_or_else(ApiError::unauthorized)173+}174+175+fn check_rate(state: &AppState, account: AccountId, class: RateClass) -> ApiResult<()> {176+ if state177+ .rate_limits178+ .lock()179+ .unwrap()180+ .check(account, class, now_secs())181+ {182+ Ok(())183+ } else {184+ Err(ApiError::too_many_requests())185+ }186+}187+91188 fn bearer_token(headers: &HeaderMap) -> Option<String> {92189 headers93190 .get(header::AUTHORIZATION)?@@ -117,7 +214,6 @@ pub struct SignInRequest {
117214 118215 119216 pub struct SignInResponse {120- pub token: String,121217 pub account_id: String,122218 pub handle: Option<String>,123219 }@@ -154,6 +250,22 @@ pub struct RegisterDaemonRequest {
154250 pub endpoint_id: String,155251 }156252 253+254+pub struct SetSharesRequest {255+ 256+ pub share_with: Vec<String>,257+}258+259+260+pub struct ProfileSettingsRequest {261+ pub private_only: bool,262+}263+264+265+pub struct CreateMachineCredentialRequest {266+ pub name: String,267+}268+157269 158270 pub struct ProfileEntry {159271 pub resource_id: String,@@ -191,17 +303,31 @@ pub struct ThemeResponse {
191303 pub theme: Vec<theme::Declaration>,192304 }193305 306+307+pub struct ConnectionTicketResponse {308+ pub ticket: String,309+ pub expires_at: u64,310+}311+194312 195-pub struct CapabilityRequest {313+pub struct RedeemTicketRequest {314+ pub ticket: String,315+ pub client_endpoint_id: String,316+}317+318+319+pub struct RedeemTicketResponse {320+ pub session_token: String,196321 pub resource_id: String,197- pub browser_endpoint_id: String,322+ pub name: String,323+ pub expires_at: u64,198324 }199325 200326 201327 pub struct CapabilityResponse {202328 /// base64url of the postcard-encoded signed capability.203329 pub capability: String,204- /// Who to present it to. Not where they are — the browser resolves that330+ /// Who to present it to. Not where they are — the client resolves that205331 /// through iroh's address lookup, from the daemon's own published record.206332 pub daemon_endpoint_id: String,207333 }@@ -212,16 +338,41 @@ pub fn router(state: Shared) -> Router {
212338 Router::new()213339 .route("/api/config", get(config))214340 .route("/api/pubkey", get(pubkey))215- .route("/api/auth/session", post(sign_in))341+ .route("/api/auth/session", post(sign_in).delete(sign_out))216342 .route("/api/me", get(me))217343 .route("/api/profile", post(claim_handle))218344 .route("/api/resources", post(create_resource).get(list_resources))219- .route("/api/resources/{id}", axum::routing::delete(delete_resource))220- .route("/api/daemon", axum::routing::put(register_daemon))345+ .route("/api/resources/{id}", delete(delete_resource))346+ .route("/api/resources/{id}/shares", put(set_resource_shares))347+ .route("/api/share-invitations", get(list_share_invitations))348+ .route(349+ "/api/share-invitations/{id}/accept",350+ post(accept_share_invitation),351+ )352+ .route(353+ "/api/share-invitations/{id}",354+ delete(decline_share_invitation),355+ )356+ .route("/api/daemon", put(register_daemon))357+ .route(358+ "/api/profile/settings",359+ get(profile_settings).put(set_profile_settings),360+ )361+ .route(362+ "/api/machine-credentials",363+ get(list_machine_credentials).post(create_machine_credential),364+ )365+ .route(366+ "/api/machine-credentials/{id}",367+ delete(revoke_machine_credential),368+ )221369 .route("/api/theme", get(read_theme).put(write_theme))222370 .route("/api/theme/properties", get(theme_properties))223371 .route("/api/profile/{handle}", get(profile))224- .route("/api/capability", post(capability))372+ .route("/api/services/{id}/ticket", post(create_connection_ticket))373+ .route("/api/tickets/redeem", post(redeem_connection_ticket))374+ .route("/api/tunnel/session", delete(delete_tunnel_session))375+ .route("/api/tunnel/capability", post(tunnel_capability))225376 .with_state(state)226377 }227378 @@ -231,6 +382,10 @@ async fn config(State(state): State<Shared>) -> impl IntoResponse {
231382 "issuer": auth::ISSUER,232383 "public_origin": state.public_origin,233384 "redirect_uri": format!("{}/auth/callback", state.public_origin),385+ "api_version": 2,386+ "minimum_cli_version": "0.2.0",387+ "server_version": env!("CARGO_PKG_VERSION"),388+ "daemon_protocol": String::from_utf8_lossy(devsite_proto::ALPN),234389 }))235390 }236391 @@ -243,14 +398,10 @@ async fn sign_in(
243398 State(state): State<Shared>,244399 Json(body): Json<SignInRequest>,245400 ) -> ApiResult<Response> {246- let claims = state247- .shoo248- .verify(&body.id_token)249- .await250- .map_err(|err| {251- tracing::warn!("rejected an id_token: {err:#}");252- ApiError(StatusCode::UNAUTHORIZED, "invalid sign-in token".into())253- })?;401+ let claims = state.shoo.verify(&body.id_token).await.map_err(|err| {402+ tracing::warn!("rejected an id_token: {err:#}");403+ ApiError(StatusCode::UNAUTHORIZED, "invalid sign-in token".into())404+ })?;254405 255406 let now = now_secs();256407 let token = auth::generate_session_token();@@ -259,15 +410,19 @@ async fn sign_in(
259410 let account = db260411 .upsert_account(&claims.sub, now)261412 .map_err(ApiError::internal)?;413+ (account.id, account.handle)414+ };415+ check_rate(&state, account_id, RateClass::Session)?;416+ {417+ let db = state.db.lock().unwrap();262418 db.create_session(263419 &auth::hash_token(&token),264- account.id,420+ account_id,265421 now + auth::SESSION_LIFETIME_SECS,266422 )267423 .map_err(ApiError::internal)?;268424 db.purge_expired_sessions(now).ok();269- (account.id, account.handle)270- };425+ }271426 272427 // HttpOnly so page scripts cannot read it; SameSite=Lax so it survives the OAuth273428 // redirect back from Shoo without being sent on cross-site POSTs.@@ -285,7 +440,6 @@ async fn sign_in(
285440 StatusCode::OK,286441 [(header::SET_COOKIE, cookie)],287442 Json(SignInResponse {288- token,289443 account_id: account_id.to_string(),290444 handle,291445 }),@@ -293,6 +447,27 @@ async fn sign_in(
293447 .into_response())294448 }295449 450+async fn sign_out(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<Response> {451+ if let Some(token) = cookie_token(&headers) {452+ state453+ .db454+ .lock()455+ .unwrap()456+ .delete_session(&auth::hash_token(&token))457+ .map_err(ApiError::internal)?;458+ }459+460+ let cookie = format!(461+ "devsite_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{}",462+ if state.public_origin.starts_with("https://") {463+ "; Secure"464+ } else {465+ ""466+ }467+ );468+ Ok((StatusCode::NO_CONTENT, [(header::SET_COOKIE, cookie)]).into_response())469+}470+296471 async fn me(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<impl IntoResponse> {297472 let id = require_account(&state, &headers)?;298473 let db = state.db.lock().unwrap();@@ -312,7 +487,9 @@ async fn claim_handle(
312487 Json(body): Json<ClaimHandleRequest>,313488 ) -> ApiResult<impl IntoResponse> {314489 let id = require_account(&state, &headers)?;315- let handle = auth::validate_handle(&body.handle).map_err(|e| ApiError::bad_request(e.to_string()))?;490+ check_rate(&state, id, RateClass::Mutation)?;491+ let handle =492+ auth::validate_handle(&body.handle).map_err(|e| ApiError::bad_request(e.to_string()))?;316493 317494 let db = state.db.lock().unwrap();318495 if let Some(existing) = db.account_by_handle(&handle).map_err(ApiError::internal)? {@@ -330,6 +507,8 @@ async fn create_resource(
330507 Json(body): Json<CreateResourceRequest>,331508 ) -> ApiResult<impl IntoResponse> {332509 let owner = require_account(&state, &headers)?;510+ check_rate(&state, owner, RateClass::Mutation)?;511+ let name = validate_resource_name(&body.name)?;333512 334513 let kind = match body.kind.as_str() {335514 "link" => ResourceKind::Link,@@ -338,61 +517,162 @@ async fn create_resource(
338517 };339518 let visibility = Visibility::parse(&body.visibility)340519 .ok_or_else(|| ApiError::bad_request("visibility must be public, private or shared"))?;520+ validate_resource_visibility(kind, visibility)?;521+ if visibility != Visibility::Shared && !body.share_with.is_empty() {522+ return Err(ApiError::bad_request(523+ "share_with is only valid when visibility is shared",524+ ));525+ }341526 342- // A link needs a URL; a service must not carry one. The local origin of a service stays527+ // A link needs a URL; a service must not carry one. The local TCP target stays343528 // in the owner's daemon config and is never uploaded.344- match kind {529+ let url = match kind {345530 ResourceKind::Link if body.url.is_none() => {346531 return Err(ApiError::bad_request("a link needs a url"))347532 }533+ ResourceKind::Link => Some(validate_public_url(body.url.as_deref().unwrap())?),348534 ResourceKind::Service if body.url.is_some() => {349535 return Err(ApiError::bad_request(350- "a service's local origin stays on your machine and must not be sent here",536+ "a service's local target stays on your machine and must not be sent here",351537 ))352538 }353- _ => {}354- }539+ ResourceKind::Service => None,540+ };355541 356542 let folder = validate_folder(body.folder.as_deref())?;357543 358544 let mut db = state.db.lock().unwrap();359545 546+ if !db547+ .resource_named(owner, name, kind)548+ .map_err(ApiError::internal)?549+ && db.resource_count(owner).map_err(ApiError::internal)? >= MAX_RESOURCES550+ {551+ return Err(ApiError::bad_request(format!(552+ "a profile may contain at most {MAX_RESOURCES} resources"553+ )));554+ }555+360556 // Resolve every share target *before* creating anything. Creating first and failing361557 // partway leaves an orphaned resource on the profile that the owner's daemon knows362558 // nothing about — it would advertise itself and then refuse every request.363- let mut viewers = Vec::new();364- for handle in &body.share_with {365- let handle =366- auth::validate_handle(handle).map_err(|e| ApiError::bad_request(e.to_string()))?;367- let viewer = db368- .account_by_handle(&handle)369- .map_err(ApiError::internal)?370- .ok_or_else(|| ApiError::bad_request(format!("no such user @{handle}")))?;371- viewers.push(viewer.id);372- }559+ let viewers = resolve_share_accounts(&db, owner, &body.share_with)?;373560 374561 let resource = db375562 .create_resource(376563 owner,377- &body.name,564+ name,378565 kind,379566 visibility,380- body.url.as_deref(),567+ url.as_deref(),381568 folder.as_deref(),382569 now_secs(),383570 )384571 .map_err(ApiError::internal)?;385572 386573 // The share list this request names is the whole share list afterwards.387- // Re-running `expose --share @carol` means the resource is Carol's, not388- // Carol's as well as whoever was named the last time it ran.389- db.set_shares(resource, &viewers).map_err(ApiError::internal)?;574+ // Re-running `expose --share @carol` means Carol is the invited recipient,575+ // not Carol plus whoever was named the last time it ran.576+ db.set_shares(resource, &viewers)577+ .map_err(ApiError::internal)?;390578 391579 Ok(Json(CreateResourceResponse {392580 resource_id: resource.to_string(),393581 }))394582 }395583 584+fn validate_resource_visibility(kind: ResourceKind, visibility: Visibility) -> ApiResult<()> {585+ match (kind, visibility) {586+ (ResourceKind::Link, Visibility::Public)587+ | (ResourceKind::Service, Visibility::Private | Visibility::Shared) => {}588+ (ResourceKind::Link, _) => {589+ return Err(ApiError::bad_request("external links must be public"))590+ }591+ (ResourceKind::Service, Visibility::Public) => {592+ return Err(ApiError::bad_request(593+ "TCP services must be private or shared with specific users",594+ ))595+ }596+ }597+ Ok(())598+}599+600+const MAX_RESOURCES: usize = 100;601+const MAX_SHARES: usize = 25;602+const MAX_RESOURCE_NAME: usize = 80;603+const MAX_PUBLIC_URL: usize = 2048;604+605+fn validate_resource_name(name: &str) -> ApiResult<&str> {606+ if name.trim() != name || name.is_empty() {607+ return Err(ApiError::bad_request(608+ "a resource name must not be blank or have leading or trailing whitespace",609+ ));610+ }611+ if name.chars().count() > MAX_RESOURCE_NAME {612+ return Err(ApiError::bad_request(format!(613+ "a resource name may be at most {MAX_RESOURCE_NAME} characters"614+ )));615+ }616+ if name.chars().any(char::is_control) {617+ return Err(ApiError::bad_request(618+ "a resource name may not contain control characters",619+ ));620+ }621+ Ok(name)622+}623+624+fn validate_public_url(value: &str) -> ApiResult<String> {625+ if value.len() > MAX_PUBLIC_URL {626+ return Err(ApiError::bad_request(format!(627+ "a link URL may be at most {MAX_PUBLIC_URL} bytes"628+ )));629+ }630+ let parsed = url::Url::parse(value)631+ .map_err(|_| ApiError::bad_request("a link URL must be a valid absolute URL"))?;632+ if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {633+ return Err(ApiError::bad_request(634+ "a link URL must use http or https and name a host",635+ ));636+ }637+ if !parsed.username().is_empty() || parsed.password().is_some() {638+ return Err(ApiError::bad_request(639+ "a link URL may not contain embedded credentials",640+ ));641+ }642+ Ok(parsed.to_string())643+}644+645+fn resolve_share_accounts(646+ db: &Db,647+ owner: AccountId,648+ handles: &[String],649+) -> ApiResult<Vec<AccountId>> {650+ if handles.len() > MAX_SHARES {651+ return Err(ApiError::bad_request(format!(652+ "a resource may be shared with at most {MAX_SHARES} people"653+ )));654+ }655+ let mut seen = HashSet::new();656+ let mut viewers = Vec::new();657+ for handle in handles {658+ let handle =659+ auth::validate_handle(handle).map_err(|e| ApiError::bad_request(e.to_string()))?;660+ let viewer = db661+ .account_by_handle(&handle)662+ .map_err(ApiError::internal)?663+ .ok_or_else(|| ApiError::bad_request(format!("no such user @{handle}")))?;664+ if viewer.id == owner {665+ return Err(ApiError::bad_request(666+ "you do not need to share a resource with yourself",667+ ));668+ }669+ if seen.insert(viewer.id) {670+ viewers.push(viewer.id);671+ }672+ }673+ Ok(viewers)674+}675+396676 /// Longest folder name. It is a label on a fold, not a description.397677 const MAX_FOLDER: usize = 40;398678 @@ -413,7 +693,9 @@ fn validate_folder(folder: Option<&str>) -> ApiResult<Option<String>> {
413693 // Control characters would survive HTML-escaping and come out as invisible414694 // damage to the summary line.415695 if folder.chars().any(char::is_control) {416- return Err(ApiError::bad_request("a folder name may not contain control characters"));696+ return Err(ApiError::bad_request(697+ "a folder name may not contain control characters",698+ ));417699 }418700 Ok(Some(folder.to_string()))419701 }@@ -428,6 +710,7 @@ async fn delete_resource(
428710 Path(id): Path<String>,429711 ) -> ApiResult<impl IntoResponse> {430712 let owner = require_account(&state, &headers)?;713+ check_rate(&state, owner, RateClass::Mutation)?;431714 let resource = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;432715 433716 let db = state.db.lock().unwrap();@@ -447,18 +730,119 @@ async fn list_resources(
447730 let owner = require_account(&state, &headers)?;448731 let db = state.db.lock().unwrap();449732 let resources = db.resources_owned_by(owner).map_err(ApiError::internal)?;733+ let resources = resources734+ .iter()735+ .map(|resource| {736+ let shares = db737+ .share_recipients(resource.id)738+ .map_err(ApiError::internal)?;739+ let shared_with = shares740+ .iter()741+ .filter(|share| share.status == ShareStatus::Accepted)742+ .map(|share| share.handle.clone())743+ .collect::<Vec<_>>();744+ Ok(serde_json::json!({745+ "resource_id": resource.id.to_string(),746+ "name": resource.name,747+ "kind": resource.kind.as_str(),748+ "visibility": resource.visibility.as_str(),749+ "url": resource.url,750+ "folder": resource.folder,751+ "shared_with": shared_with,752+ "shares": shares.iter().map(|share| serde_json::json!({753+ "handle": share.handle,754+ "status": share.status.as_str(),755+ })).collect::<Vec<_>>(),756+ }))757+ })758+ .collect::<ApiResult<Vec<_>>>()?;450759 Ok(Json(serde_json::json!({451- "resources": resources.iter().map(|r| serde_json::json!({452- "resource_id": r.id.to_string(),453- "name": r.name,454- "kind": r.kind.as_str(),455- "visibility": r.visibility.as_str(),456- "url": r.url,457- "folder": r.folder,760+ "resources": resources761+ })))762+}763+764+async fn set_resource_shares(765+ State(state): State<Shared>,766+ headers: HeaderMap,767+ Path(id): Path<String>,768+ Json(body): Json<SetSharesRequest>,769+) -> ApiResult<impl IntoResponse> {770+ let owner = require_account(&state, &headers)?;771+ check_rate(&state, owner, RateClass::Mutation)?;772+ let resource_id = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;773+774+ let mut db = state.db.lock().unwrap();775+ let resource = db776+ .resource(resource_id)777+ .map_err(ApiError::internal)?778+ .filter(|resource| resource.owner_id == owner)779+ .ok_or_else(ApiError::not_found)?;780+ if resource.visibility != Visibility::Shared && !body.share_with.is_empty() {781+ return Err(ApiError::bad_request(782+ "only a shared resource can name viewers",783+ ));784+ }785+ let viewers = resolve_share_accounts(&db, owner, &body.share_with)?;786+ db.set_shares(resource_id, &viewers)787+ .map_err(ApiError::internal)?;788+ Ok(StatusCode::NO_CONTENT)789+}790+791+async fn list_share_invitations(792+ State(state): State<Shared>,793+ headers: HeaderMap,794+) -> ApiResult<impl IntoResponse> {795+ let viewer = require_browser_account(&state, &headers)?;796+ let db = state.db.lock().unwrap();797+ let invitations = db.incoming_shares(viewer).map_err(ApiError::internal)?;798+ Ok(Json(serde_json::json!({799+ "shares": invitations.iter().map(|invitation| serde_json::json!({800+ "resource_id": invitation.resource.id.to_string(),801+ "name": invitation.resource.name,802+ "kind": invitation.resource.kind.as_str(),803+ "url": invitation.resource.url,804+ "owner_handle": invitation.owner_handle,805+ "status": invitation.status.as_str(),458806 })).collect::<Vec<_>>()459807 })))460808 }461809 810+async fn accept_share_invitation(811+ State(state): State<Shared>,812+ headers: HeaderMap,813+ Path(id): Path<String>,814+) -> ApiResult<impl IntoResponse> {815+ let viewer = require_browser_account(&state, &headers)?;816+ check_rate(&state, viewer, RateClass::Mutation)?;817+ let resource = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;818+ let mut db = state.db.lock().unwrap();819+ if !db820+ .accept_share(viewer, resource)821+ .map_err(ApiError::internal)?822+ {823+ return Err(ApiError::not_found());824+ }825+ Ok(StatusCode::NO_CONTENT)826+}827+828+async fn decline_share_invitation(829+ State(state): State<Shared>,830+ headers: HeaderMap,831+ Path(id): Path<String>,832+) -> ApiResult<impl IntoResponse> {833+ let viewer = require_browser_account(&state, &headers)?;834+ check_rate(&state, viewer, RateClass::Mutation)?;835+ let resource = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;836+ let mut db = state.db.lock().unwrap();837+ if !db838+ .decline_share(viewer, resource)839+ .map_err(ApiError::internal)?840+ {841+ return Err(ApiError::not_found());842+ }843+ Ok(StatusCode::NO_CONTENT)844+}845+462846 /// Record which endpoint id this account's daemon answers on.463847 ///464848 /// Called once when a daemon starts, not on a timer. The id is stable across@@ -471,10 +855,13 @@ async fn register_daemon(
471855 Json(body): Json<RegisterDaemonRequest>,472856 ) -> ApiResult<impl IntoResponse> {473857 let account = require_account(&state, &headers)?;858+ check_rate(&state, account, RateClass::Mutation)?;474859 // Validated before storing: a malformed id would be handed to browsers as475860 // something to go looking for, and fail somewhere much less obvious.476861 if parse_endpoint_id(&body.endpoint_id).is_none() {477- return Err(ApiError::bad_request("endpoint_id is not a 32 byte hex key"));862+ return Err(ApiError::bad_request(863+ "endpoint_id is not a 32 byte hex key",864+ ));478865 }479866 480867 let db = state.db.lock().unwrap();@@ -483,6 +870,129 @@ async fn register_daemon(
483870 Ok(StatusCode::NO_CONTENT)484871 }485872 873+// -- dashboard ---------------------------------------------------------------874+875+async fn profile_settings(876+ State(state): State<Shared>,877+ headers: HeaderMap,878+) -> ApiResult<impl IntoResponse> {879+ let account = require_browser_account(&state, &headers)?;880+ let db = state.db.lock().unwrap();881+ let private_only = db882+ .profile_private_only(account)883+ .map_err(ApiError::internal)?;884+ Ok(Json(serde_json::json!({ "private_only": private_only })))885+}886+887+async fn set_profile_settings(888+ State(state): State<Shared>,889+ headers: HeaderMap,890+ Json(body): Json<ProfileSettingsRequest>,891+) -> ApiResult<impl IntoResponse> {892+ let account = require_browser_account(&state, &headers)?;893+ check_rate(&state, account, RateClass::Mutation)?;894+ let db = state.db.lock().unwrap();895+ db.set_profile_private_only(account, body.private_only)896+ .map_err(ApiError::internal)?;897+ Ok(Json(898+ serde_json::json!({ "private_only": body.private_only }),899+ ))900+}901+902+const MAX_MACHINE_CREDENTIALS: usize = 10;903+const MAX_MACHINE_NAME: usize = 60;904+905+fn validate_machine_name(name: &str) -> ApiResult<&str> {906+ if name.trim() != name || name.is_empty() {907+ return Err(ApiError::bad_request("a machine name may not be blank"));908+ }909+ if name.chars().count() > MAX_MACHINE_NAME {910+ return Err(ApiError::bad_request(format!(911+ "a machine name may be at most {MAX_MACHINE_NAME} characters"912+ )));913+ }914+ if name.chars().any(char::is_control) {915+ return Err(ApiError::bad_request(916+ "a machine name may not contain control characters",917+ ));918+ }919+ Ok(name)920+}921+922+async fn list_machine_credentials(923+ State(state): State<Shared>,924+ headers: HeaderMap,925+) -> ApiResult<impl IntoResponse> {926+ let account = require_browser_account(&state, &headers)?;927+ let db = state.db.lock().unwrap();928+ let credentials = db929+ .machine_credentials(account)930+ .map_err(ApiError::internal)?;931+ Ok(Json(serde_json::json!({932+ "credentials": credentials.iter().map(|credential| serde_json::json!({933+ "id": credential.id,934+ "name": credential.name,935+ "created_at": credential.created_at,936+ "last_used_at": credential.last_used_at,937+ })).collect::<Vec<_>>()938+ })))939+}940+941+async fn create_machine_credential(942+ State(state): State<Shared>,943+ headers: HeaderMap,944+ Json(body): Json<CreateMachineCredentialRequest>,945+) -> ApiResult<impl IntoResponse> {946+ let account = require_browser_account(&state, &headers)?;947+ check_rate(&state, account, RateClass::Credential)?;948+ let name = validate_machine_name(&body.name)?;949+ let token = auth::generate_machine_token();950+ let id = auth::generate_machine_credential_id();951+ let now = now_secs();952+953+ let db = state.db.lock().unwrap();954+ if db955+ .active_machine_credential_count(account)956+ .map_err(ApiError::internal)?957+ >= MAX_MACHINE_CREDENTIALS958+ {959+ return Err(ApiError::bad_request(format!(960+ "an account may have at most {MAX_MACHINE_CREDENTIALS} active machine credentials"961+ )));962+ }963+ db.create_machine_credential(&id, account, name, &auth::hash_token(&token), now)964+ .map_err(ApiError::internal)?;965+ Ok((966+ StatusCode::CREATED,967+ Json(serde_json::json!({968+ "credential": {969+ "id": id,970+ "name": name,971+ "created_at": now,972+ "last_used_at": null,973+ },974+ "token": token,975+ })),976+ ))977+}978+979+async fn revoke_machine_credential(980+ State(state): State<Shared>,981+ headers: HeaderMap,982+ Path(id): Path<String>,983+) -> ApiResult<impl IntoResponse> {984+ let account = require_browser_account(&state, &headers)?;985+ check_rate(&state, account, RateClass::Credential)?;986+ let db = state.db.lock().unwrap();987+ if !db988+ .revoke_machine_credential(account, &id, now_secs())989+ .map_err(ApiError::internal)?990+ {991+ return Err(ApiError::not_found());992+ }993+ Ok(StatusCode::NO_CONTENT)994+}995+486996 // -- themes --------------------------------------------------------------------487997 488998 /// The properties a theme may set, and what each one accepts.@@ -497,7 +1007,10 @@ async fn theme_properties() -> impl IntoResponse {
4971007 }))4981008 }4991009 500-async fn read_theme(State(state): State<Shared>, headers: HeaderMap) -> ApiResult<impl IntoResponse> {1010+async fn read_theme(1011+ State(state): State<Shared>,1012+ headers: HeaderMap,1013+) -> ApiResult<impl IntoResponse> {5011014 let account = require_account(&state, &headers)?;5021015 let css = {5031016 let db = state.db.lock().unwrap();@@ -516,6 +1029,13 @@ async fn write_theme(
5161029 Json(body): Json<ThemeRequest>,5171030 ) -> ApiResult<impl IntoResponse> {5181031 let account = require_account(&state, &headers)?;1032+ check_rate(&state, account, RateClass::Mutation)?;1033+ const MAX_THEME_BYTES: usize = 16 * 1024;1034+ if body.css.len() > MAX_THEME_BYTES {1035+ return Err(ApiError::bad_request(format!(1036+ "a theme may be at most {MAX_THEME_BYTES} bytes"1037+ )));1038+ }5191039 5201040 // Validated here, once, before anything is stored. Every later read — the5211041 // profile page, the CLI, this endpoint — can then treat the column as@@ -546,9 +1066,19 @@ async fn profile(
5461066 .account_by_handle(&handle)5471067 .map_err(ApiError::internal)?5481068 .ok_or_else(ApiError::not_found)?;1069+ if db1070+ .profile_private_only(owner.id)1071+ .map_err(ApiError::internal)?1072+ && viewer != Some(owner.id)1073+ {1074+ return Err(ApiError::not_found());1075+ }5491076 5501077 let mut entries = Vec::new();551- for resource in db.resources_owned_by(owner.id).map_err(ApiError::internal)? {1078+ for resource in db1079+ .resources_owned_by(owner.id)1080+ .map_err(ApiError::internal)?1081+ {5521082 let shared_with = db.shared_with(resource.id).map_err(ApiError::internal)?;5531083 // The single choke point. A resource that fails here is not merely hidden from the5541084 // rendering — it never enters the response at all.@@ -613,49 +1143,171 @@ fn to_entry(resource: &crate::db::Resource, owner_handle: Option<String>) -> Pro
6131143 }6141144 }6151145 616-/// Issue a capability, if and only if the caller may view the resource.1146+const CONNECTION_TICKET_LIFETIME_SECS: u64 = 2 * 60;1147+const TUNNEL_SESSION_LIFETIME_SECS: u64 = 8 * 60 * 60;1148+const CONNECTION_TICKET_PREFIX: &str = "dst_";1149+const TUNNEL_SESSION_PREFIX: &str = "dss_";1150+1151+/// Mint a short-lived, single-use bootstrap ticket from an authenticated browser.1152+async fn create_connection_ticket(1153+ State(state): State<Shared>,1154+ headers: HeaderMap,1155+ Path(id): Path<String>,1156+) -> ApiResult<impl IntoResponse> {1157+ let viewer = require_browser_account(&state, &headers)?;1158+ check_rate(&state, viewer, RateClass::Capability)?;1159+ let resource_id = ResourceId::from_str(&id).map_err(|_| ApiError::not_found())?;1160+ let now = now_secs();1161+ let ticket = prefixed_token(CONNECTION_TICKET_PREFIX);1162+ let expires_at = now + CONNECTION_TICKET_LIFETIME_SECS;1163+1164+ let db = state.db.lock().unwrap();1165+ let resource = db1166+ .resource(resource_id)1167+ .map_err(ApiError::internal)?1168+ .ok_or_else(ApiError::not_found)?;1169+ let shared_with = db.shared_with(resource_id).map_err(ApiError::internal)?;1170+ if resource.kind != ResourceKind::Service1171+ || !can_view(1172+ Some(viewer),1173+ resource.owner_id,1174+ resource.visibility,1175+ &shared_with,1176+ )1177+ {1178+ return Err(ApiError::not_found());1179+ }1180+ db.create_connection_ticket(1181+ &auth::hash_token(&ticket),1182+ viewer,1183+ resource_id,1184+ now,1185+ expires_at,1186+ )1187+ .map_err(ApiError::internal)?;1188+1189+ Ok((1190+ StatusCode::CREATED,1191+ Json(ConnectionTicketResponse { ticket, expires_at }),1192+ ))1193+}1194+1195+/// Consume a browser ticket and replace it with a session bound to the CLI's1196+/// ephemeral Iroh endpoint key. The session should remain in process memory only.1197+async fn redeem_connection_ticket(1198+ State(state): State<Shared>,1199+ Json(body): Json<RedeemTicketRequest>,1200+) -> ApiResult<impl IntoResponse> {1201+ if !valid_prefixed_token(&body.ticket, CONNECTION_TICKET_PREFIX) {1202+ return Err(invalid_ticket());1203+ }1204+ let client_key = parse_endpoint_id(&body.client_endpoint_id)1205+ .ok_or_else(|| ApiError::bad_request("client_endpoint_id is not a 32 byte hex key"))?;1206+ let client_endpoint_id = data_encoding::HEXLOWER.encode(&client_key);1207+ let now = now_secs();1208+ let session_token = prefixed_token(TUNNEL_SESSION_PREFIX);1209+ let expires_at = now + TUNNEL_SESSION_LIFETIME_SECS;1210+1211+ let resource = {1212+ let db = state.db.lock().unwrap();1213+ let (viewer, resource_id) = db1214+ .consume_connection_ticket(&auth::hash_token(&body.ticket), now)1215+ .map_err(ApiError::internal)?1216+ .ok_or_else(invalid_ticket)?;1217+ let resource = db1218+ .resource(resource_id)1219+ .map_err(ApiError::internal)?1220+ .ok_or_else(invalid_ticket)?;1221+ let shared_with = db.shared_with(resource_id).map_err(ApiError::internal)?;1222+ if resource.kind != ResourceKind::Service1223+ || !can_view(1224+ Some(viewer),1225+ resource.owner_id,1226+ resource.visibility,1227+ &shared_with,1228+ )1229+ {1230+ return Err(invalid_ticket());1231+ }1232+ db.create_tunnel_session(1233+ &auth::hash_token(&session_token),1234+ viewer,1235+ resource_id,1236+ &client_endpoint_id,1237+ now,1238+ expires_at,1239+ )1240+ .map_err(ApiError::internal)?;1241+ resource1242+ };1243+1244+ Ok(Json(RedeemTicketResponse {1245+ session_token,1246+ resource_id: resource.id.to_string(),1247+ name: resource.name,1248+ expires_at,1249+ }))1250+}1251+1252+/// Issue a fresh, short capability for one local TCP connection.6171253 ///6181254 /// Denials are 404 rather than 403 throughout: a 403 would confirm that a resource id6191255 /// exists, which is enough to enumerate someone's private services.620-async fn capability(1256+async fn tunnel_capability(6211257 State(state): State<Shared>,6221258 headers: HeaderMap,623- Json(body): Json<CapabilityRequest>,6241259 ) -> ApiResult<impl IntoResponse> {625- let viewer = require_account(&state, &headers)?;626-627- let resource_id = ResourceId::from_str(&body.resource_id).map_err(|_| ApiError::not_found())?;628- let browser_key =629- parse_endpoint_id(&body.browser_endpoint_id).ok_or_else(|| ApiError::bad_request("browser_endpoint_id is not a 32 byte hex key"))?;1260+ let token = bearer_token(&headers).ok_or_else(invalid_tunnel_session)?;1261+ if !valid_prefixed_token(&token, TUNNEL_SESSION_PREFIX) {1262+ return Err(invalid_tunnel_session());1263+ }1264+ let now = now_secs();6301265 631- let (resource, endpoint_id) = {1266+ let (session, resource, endpoint_id) = {6321267 let db = state.db.lock().unwrap();1268+ let session = db1269+ .tunnel_session(&auth::hash_token(&token), now)1270+ .map_err(ApiError::internal)?1271+ .ok_or_else(invalid_tunnel_session)?;6331272 let resource = db634- .resource(resource_id)1273+ .resource(session.resource_id)6351274 .map_err(ApiError::internal)?6361275 .ok_or_else(ApiError::not_found)?;637- let shared_with = db.shared_with(resource_id).map_err(ApiError::internal)?;1276+ let shared_with = db1277+ .shared_with(session.resource_id)1278+ .map_err(ApiError::internal)?;6381279 639- if !can_view(Some(viewer), resource.owner_id, resource.visibility, &shared_with) {1280+ if !can_view(1281+ Some(session.viewer_id),1282+ resource.owner_id,1283+ resource.visibility,1284+ &shared_with,1285+ ) {6401286 tracing::warn!(641- viewer = %viewer,642- resource = %resource_id,1287+ viewer = %session.viewer_id,1288+ resource = %session.resource_id,6431289 "refused a capability request"6441290 );6451291 return Err(ApiError::not_found());6461292 }6471293 if resource.kind != ResourceKind::Service {648- return Err(ApiError::bad_request("that resource is a link, not a service"));1294+ return Err(ApiError::bad_request(1295+ "that resource is a link, not a service",1296+ ));6491297 }6501298 let endpoint_id = db6511299 .daemon_endpoint(resource.owner_id)6521300 .map_err(ApiError::internal)?;653- (resource, endpoint_id)1301+ (session, resource, endpoint_id)6541302 };1303+ check_rate(&state, session.viewer_id, RateClass::Capability)?;1304+ let client_key = parse_endpoint_id(&session.client_endpoint_id).ok_or_else(|| {1305+ ApiError::internal(anyhow::anyhow!("stored client endpoint id is invalid"))1306+ })?;6551307 6561308 // The only thing that can be answered from here. Whether the daemon is657- // *running* is not knowable at this end without asking it, so the browser658- // asks it directly and reports what it finds. This case is the narrower one:1309+ // *running* is not knowable at this end without asking it, so the client discovers1310+ // that when it connects. This case is the narrower one:6591311 // no daemon has ever registered, so there is not even an address to try.6601312 let endpoint_id = endpoint_id.ok_or_else(|| {6611313 ApiError(@@ -664,12 +1316,13 @@ async fn capability(
6641316 )6651317 })?;6661318 667- let audience = parse_endpoint_id(&endpoint_id)668- .ok_or_else(|| ApiError::internal(anyhow::anyhow!("stored daemon endpoint id is invalid")))?;1319+ let audience = parse_endpoint_id(&endpoint_id).ok_or_else(|| {1320+ ApiError::internal(anyhow::anyhow!("stored daemon endpoint id is invalid"))1321+ })?;6691322 6701323 let capability = state6711324 .issuer672- .issue(viewer, resource.id, audience, browser_key, now_secs())1325+ .issue(session.viewer_id, resource.id, audience, client_key, now)6731326 .map_err(ApiError::internal)?;6741327 6751328 Ok(Json(CapabilityResponse {@@ -678,6 +1331,48 @@ async fn capability(
6781331 }))6791332 }6801333 1334+async fn delete_tunnel_session(1335+ State(state): State<Shared>,1336+ headers: HeaderMap,1337+) -> ApiResult<impl IntoResponse> {1338+ let token = bearer_token(&headers).ok_or_else(invalid_tunnel_session)?;1339+ if !valid_prefixed_token(&token, TUNNEL_SESSION_PREFIX) {1340+ return Err(invalid_tunnel_session());1341+ }1342+ // DELETE is idempotent. A share or resource revocation may already have1343+ // cascaded the row away while the CLI was still running.1344+ state1345+ .db1346+ .lock()1347+ .unwrap()1348+ .delete_tunnel_session(&auth::hash_token(&token))1349+ .map_err(ApiError::internal)?;1350+ Ok(StatusCode::NO_CONTENT)1351+}1352+1353+fn prefixed_token(prefix: &str) -> String {1354+ format!("{prefix}{}", auth::generate_session_token())1355+}1356+1357+fn valid_prefixed_token(token: &str, prefix: &str) -> bool {1358+ token.len() == prefix.len() + 431359+ && token.starts_with(prefix)1360+ && token[prefix.len()..]1361+ .bytes()1362+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))1363+}1364+1365+fn invalid_ticket() -> ApiError {1366+ ApiError(StatusCode::UNAUTHORIZED, "invalid or expired ticket".into())1367+}1368+1369+fn invalid_tunnel_session() -> ApiError {1370+ ApiError(1371+ StatusCode::UNAUTHORIZED,1372+ "invalid or expired tunnel session".into(),1373+ )1374+}1375+6811376 /// Parse a hex iroh endpoint id into raw key bytes.6821377 fn parse_endpoint_id(text: &str) -> Option<KeyBytes> {6831378 let raw = data_encoding::HEXLOWER_PERMISSIVE@@ -690,6 +1385,15 @@ fn parse_endpoint_id(text: &str) -> Option<KeyBytes> {
6901385 mod tests {6911386 use super::*;6921387 1388+ 1389+ fn links_are_public_and_tcp_services_are_not() {1390+ assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Public).is_ok());1391+ assert!(validate_resource_visibility(ResourceKind::Link, Visibility::Private).is_err());1392+ assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Private).is_ok());1393+ assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Shared).is_ok());1394+ assert!(validate_resource_visibility(ResourceKind::Service, Visibility::Public).is_err());1395+ }1396+6931397 6941398 fn parses_a_valid_endpoint_id() {6951399 let hex = "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7a0";@@ -699,11 +1403,36 @@ mod tests {
6991403 7001404 7011405 fn rejects_endpoint_ids_of_the_wrong_shape() {702- for bad in ["", "abcd", "zz", "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7"] {703- assert!(parse_endpoint_id(bad).is_none(), "{bad:?} should be rejected");1406+ for bad in [1407+ "",1408+ "abcd",1409+ "zz",1410+ "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7",1411+ ] {1412+ assert!(1413+ parse_endpoint_id(bad).is_none(),1414+ "{bad:?} should be rejected"1415+ );7041416 }7051417 }7061418 1419+ 1420+ fn bootstrap_and_session_tokens_have_distinct_bounded_shapes() {1421+ let ticket = prefixed_token(CONNECTION_TICKET_PREFIX);1422+ let session = prefixed_token(TUNNEL_SESSION_PREFIX);1423+ assert!(valid_prefixed_token(&ticket, CONNECTION_TICKET_PREFIX));1424+ assert!(valid_prefixed_token(&session, TUNNEL_SESSION_PREFIX));1425+ assert!(!valid_prefixed_token(&ticket, TUNNEL_SESSION_PREFIX));1426+ assert!(!valid_prefixed_token(1427+ &format!("{ticket}extra"),1428+ CONNECTION_TICKET_PREFIX1429+ ));1430+ assert!(!valid_prefixed_token(1431+ "dst_not/base64url________________________________",1432+ CONNECTION_TICKET_PREFIX1433+ ));1434+ }1435+7071436 7081437 fn blank_folder_names_are_the_same_as_none() {7091438 // Otherwise a profile grows a fold with no label that nothing can remove,@@ -711,7 +1440,10 @@ mod tests {
7111440 for blank in [Some(""), Some(" "), Some("\t"), None] {7121441 assert_eq!(validate_folder(blank).unwrap(), None, "{blank:?}");7131442 }714- assert_eq!(validate_folder(Some(" Games ")).unwrap().as_deref(), Some("Games"));1443+ assert_eq!(1444+ validate_folder(Some(" Games ")).unwrap().as_deref(),1445+ Some("Games")1446+ );7151447 }7161448 7171449 @@ -722,12 +1454,58 @@ mod tests {
7221454 assert!(validate_folder(Some("two\nlines")).is_err());7231455 }7241456 1457+ 1458+ fn resource_names_are_bounded_and_printable() {1459+ assert_eq!(validate_resource_name("Agent").unwrap(), "Agent");1460+ for bad in ["", " Agent", "Agent ", "two\nlines"] {1461+ assert!(1462+ validate_resource_name(bad).is_err(),1463+ "{bad:?} should be rejected"1464+ );1465+ }1466+ assert!(validate_resource_name(&"x".repeat(MAX_RESOURCE_NAME)).is_ok());1467+ assert!(validate_resource_name(&"x".repeat(MAX_RESOURCE_NAME + 1)).is_err());1468+ }1469+1470+ 1471+ fn public_links_are_http_urls_without_embedded_credentials() {1472+ assert_eq!(1473+ validate_public_url("https://example.com/path").unwrap(),1474+ "https://example.com/path"1475+ );1476+ for bad in [1477+ "javascript:alert(1)",1478+ "data:text/html,hi",1479+ "/relative",1480+ "https://user:secret@example.com/",1481+ "not a url",1482+ ] {1483+ assert!(1484+ validate_public_url(bad).is_err(),1485+ "{bad:?} should be rejected"1486+ );1487+ }1488+ }1489+1490+ 1491+ fn rate_limits_reset_after_the_window() {1492+ let account = AccountId::from_bytes([9; 16]);1493+ let mut limits = RateLimits::default();1494+ for _ in 0..RateClass::Session.limit() {1495+ assert!(limits.check(account, RateClass::Session, 100));1496+ }1497+ assert!(!limits.check(account, RateClass::Session, 100));1498+ assert!(limits.check(account, RateClass::Session, 160));1499+ }1500+7251501 7261502 fn reads_the_session_cookie_among_others() {7271503 let mut headers = HeaderMap::new();7281504 headers.insert(7291505 header::COOKIE,730- "theme=dark; devsite_session=abc123; other=x".parse().unwrap(),1506+ "theme=dark; devsite_session=abc123; other=x"1507+ .parse()1508+ .unwrap(),7311509 );7321510 assert_eq!(cookie_token(&headers).as_deref(), Some("abc123"));7331511 }@@ -151,6 +151,23 @@ pub fn generate_session_token() -> String {
151151 data_encoding::BASE64URL_NOPAD.encode(&bytes)152152 }153153 154+/// Long-lived bearer credential for one named machine. The prefix keeps it155+/// recognizable in password managers and logs without weakening its entropy.156+pub fn generate_machine_token() -> String {157+ format!("dsm_{}", generate_session_token())158+}159+160+pub fn generate_machine_credential_id() -> String {161+ let mut bytes = [0u8; 16];162+ getrandom_fill(&mut bytes);163+ format!(164+ "machine_{}",165+ data_encoding::BASE32_NOPAD166+ .encode(&bytes)167+ .to_ascii_lowercase()168+ )169+}170+154171 pub fn hash_token(token: &str) -> String {155172 let digest = Sha256::digest(token.as_bytes());156173 data_encoding::HEXLOWER.encode(&digest)@@ -177,7 +194,14 @@ pub fn validate_handle(handle: &str) -> Result<String> {
177194 if handle.starts_with('-') || handle.starts_with('_') {178195 bail!("handles must start with a letter or digit");179196 }180- Ok(handle.to_ascii_lowercase())197+ let handle = handle.to_ascii_lowercase();198+ // This is deliberately not a profanity filter. These names imply an199+ // official dev.site identity and are the small set worth reserving.200+ const RESERVED: &[&str] = &["admin", "devsite", "security", "support"];201+ if RESERVED.contains(&handle.as_str()) {202+ bail!("that handle is reserved");203+ }204+ Ok(handle)181205 }182206 183207 @@ -202,8 +226,13 @@ mod tests {
202226 let b = generate_session_token();203227 assert_ne!(a, b);204228 assert!(a.len() >= 43);205- assert_ne!(hash_token(&a), a, "the raw token must never be the stored value");229+ assert_ne!(230+ hash_token(&a),231+ a,232+ "the raw token must never be the stored value"233+ );206234 assert_eq!(hash_token(&a), hash_token(&a));235+ assert!(generate_machine_token().starts_with("dsm_"));207236 }208237 209238 @@ -214,8 +243,22 @@ mod tests {
214243 215244 216245 fn rejects_handles_that_would_be_trouble_in_a_url_or_shell() {217- for bad in ["a", "", "has space", "semi;colon", "../etc", "-leading", "slash/es"] {246+ for bad in [247+ "a",248+ "",249+ "has space",250+ "semi;colon",251+ "../etc",252+ "-leading",253+ "slash/es",254+ ] {218255 assert!(validate_handle(bad).is_err(), "{bad:?} should be rejected");219256 }257+ for reserved in ["admin", "DevSite", "security", "support"] {258+ assert!(259+ validate_handle(reserved).is_err(),260+ "{reserved:?} should be reserved"261+ );262+ }220263 }221264 }@@ -1,7 +1,7 @@
11 //! SQLite storage for the control plane.22 //!33 //! The control plane stores metadata and permissions. It never stores or sees the contents4-//! of a private service — that traffic goes browser-to-daemon over the relay.4+//! of a private service — that traffic goes client-to-daemon over the relay.55 66 use std::str::FromStr;77 @@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS resources (
3636 name TEXT NOT NULL,3737 kind TEXT NOT NULL CHECK (kind IN ('link', 'service')),3838 visibility TEXT NOT NULL CHECK (visibility IN ('public', 'private', 'shared')),39- -- Set for links (the external URL). Always NULL for services: the local origin lives39+ -- Set for links (the external URL). Always NULL for services: the local target lives4040 -- only in the owner's daemon config and is never uploaded here.4141 url TEXT,4242 created_at INTEGER NOT NULL@@ -77,7 +77,7 @@ const MIGRATIONS: &[&str] = &[
7777 // Presence is gone, and with it the 15-second heartbeat that maintained these7878 // three columns. A daemon's endpoint id is derived from its secret key and7979 // never changes, so it is registered once; its address is published by the80- // daemon itself through iroh's address lookup and resolved by the browser,80+ // daemon itself through iroh's address lookup and resolved by the client,8181 // which is why the control plane no longer stores a relay url. What remains8282 // is the identity a capability is addressed to.8383 "ALTER TABLE daemons DROP COLUMN relay_url;@@ -88,6 +88,49 @@ const MIGRATIONS: &[&str] = &[
8888 // to create, nothing to delete, no way to be left holding an empty one, and8989 // renaming is retagging.9090 "ALTER TABLE resources ADD COLUMN folder TEXT;",91+ // A profile can be kept as an owner-only dashboard, and CLI/daemon access92+ // uses credentials that can be named and revoked independently of the93+ // browser's short-lived session.94+ "ALTER TABLE profiles ADD COLUMN private_only INTEGER NOT NULL DEFAULT 095+ CHECK (private_only IN (0, 1));96+ CREATE TABLE machine_credentials (97+ id TEXT PRIMARY KEY,98+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,99+ name TEXT NOT NULL,100+ token_hash TEXT NOT NULL UNIQUE,101+ created_at INTEGER NOT NULL,102+ last_used_at INTEGER103+ );104+ CREATE INDEX machine_credentials_account105+ ON machine_credentials(account_id, created_at);",106+ // Accepted grants stay in `shares`, which is the only table older binaries107+ // know how to authorize. Keeping pending rows separate makes a rollback108+ // fail closed instead of accidentally treating invitations as grants.109+ "CREATE TABLE share_invitations (110+ resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,111+ viewer_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,112+ status TEXT NOT NULL CHECK (status IN ('pending', 'declined')),113+ PRIMARY KEY (resource_id, viewer_id)114+ );115+ CREATE INDEX share_invitations_viewer_status116+ ON share_invitations(viewer_id, status);",117+ // Browser-minted connection tickets are single-use bootstrap secrets. Redeeming one118+ // replaces it with a longer in-memory-only CLI session bound to that CLI's Iroh key.119+ "CREATE TABLE connection_tickets (120+ token_hash TEXT PRIMARY KEY,121+ viewer_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,122+ resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,123+ expires_at INTEGER NOT NULL124+ );125+ CREATE INDEX connection_tickets_expiry ON connection_tickets(expires_at);126+ CREATE TABLE tunnel_sessions (127+ token_hash TEXT PRIMARY KEY,128+ viewer_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,129+ resource_id TEXT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,130+ client_endpoint_id TEXT NOT NULL,131+ expires_at INTEGER NOT NULL132+ );133+ CREATE INDEX tunnel_sessions_expiry ON tunnel_sessions(expires_at);",91134 ];92135 93136 fn migrate(conn: &Connection) -> Result<()> {@@ -123,6 +166,61 @@ pub struct Resource {
123166 pub folder: Option<String>,124167 }125168 169+170+pub struct MachineCredential {171+ pub id: String,172+ pub name: String,173+ pub created_at: u64,174+ pub last_used_at: Option<u64>,175+}176+177+178+pub enum ShareStatus {179+ Pending,180+ Accepted,181+ Declined,182+}183+184+impl ShareStatus {185+ pub fn as_str(self) -> &'static str {186+ match self {187+ Self::Pending => "pending",188+ Self::Accepted => "accepted",189+ Self::Declined => "declined",190+ }191+ }192+193+ fn parse(value: &str) -> Result<Self> {194+ match value {195+ "pending" => Ok(Self::Pending),196+ "accepted" => Ok(Self::Accepted),197+ "declined" => Ok(Self::Declined),198+ _ => anyhow::bail!("unknown share status"),199+ }200+ }201+}202+203+204+pub struct ShareRecipient {205+ pub handle: String,206+ pub status: ShareStatus,207+}208+209+210+pub struct IncomingShare {211+ pub resource: Resource,212+ pub owner_handle: String,213+ pub status: ShareStatus,214+}215+216+217+pub struct TunnelSession {218+ pub viewer_id: AccountId,219+ pub resource_id: ResourceId,220+ pub client_endpoint_id: String,221+ pub expires_at: u64,222+}223+126224 127225 pub enum ResourceKind {128226 Link,@@ -264,7 +362,12 @@ impl Db {
264362 265363 // -- sessions ---------------------------------------------------------------266364 267- pub fn create_session(&self, token_hash: &str, account: AccountId, expires_at: u64) -> Result<()> {365+ pub fn create_session(366+ &self,367+ token_hash: &str,368+ account: AccountId,369+ expires_at: u64,370+ ) -> Result<()> {268371 self.conn.execute(269372 "INSERT INTO sessions (token_hash, account_id, expires_at) VALUES (?1, ?2, ?3)",270373 params![token_hash, account.to_string(), expires_at as i64],@@ -289,13 +392,131 @@ impl Db {
289392 }290393 291394 pub fn purge_expired_sessions(&self, now: u64) -> Result<()> {292- self.conn293- .execute("DELETE FROM sessions WHERE expires_at <= ?1", params![now as i64])?;395+ self.conn.execute(396+ "DELETE FROM sessions WHERE expires_at <= ?1",397+ params![now as i64],398+ )?;294399 Ok(())295400 }296401 402+ pub fn delete_session(&self, token_hash: &str) -> Result<bool> {403+ Ok(self.conn.execute(404+ "DELETE FROM sessions WHERE token_hash = ?1",405+ params![token_hash],406+ )? > 0)407+ }408+409+ // -- machine credentials --------------------------------------------------410+411+ pub fn create_machine_credential(412+ &self,413+ id: &str,414+ account: AccountId,415+ name: &str,416+ token_hash: &str,417+ now: u64,418+ ) -> Result<()> {419+ self.conn.execute(420+ "INSERT INTO machine_credentials421+ (id, account_id, name, token_hash, created_at, last_used_at)422+ VALUES (?1, ?2, ?3, ?4, ?5, NULL)",423+ params![id, account.to_string(), name, token_hash, now as i64],424+ )?;425+ Ok(())426+ }427+428+ /// Resolve an active machine credential. Last-used writes are coalesced to429+ /// at most once per hour so ordinary CLI and daemon traffic stays read-mostly.430+ pub fn machine_account(&self, token_hash: &str, now: u64) -> Result<Option<Account>> {431+ let id: Option<String> = self432+ .conn433+ .query_row(434+ "SELECT account_id FROM machine_credentials WHERE token_hash = ?1",435+ params![token_hash],436+ |row| row.get(0),437+ )438+ .optional()?;439+ let Some(id) = id else {440+ return Ok(None);441+ };442+443+ self.conn.execute(444+ "UPDATE machine_credentials SET last_used_at = ?1445+ WHERE token_hash = ?2446+ AND (last_used_at IS NULL OR last_used_at < ?3)",447+ params![now as i64, token_hash, now.saturating_sub(60 * 60) as i64],448+ )?;449+ self.account_by_id(AccountId::from_str(&id)?)450+ }451+452+ pub fn machine_credentials(&self, account: AccountId) -> Result<Vec<MachineCredential>> {453+ let mut stmt = self.conn.prepare(454+ "SELECT id, name, created_at, last_used_at455+ FROM machine_credentials WHERE account_id = ?1 ORDER BY created_at DESC",456+ )?;457+ let rows = stmt458+ .query_map(params![account.to_string()], |row| {459+ Ok(MachineCredential {460+ id: row.get(0)?,461+ name: row.get(1)?,462+ created_at: row.get::<_, i64>(2)? as u64,463+ last_used_at: row.get::<_, Option<i64>>(3)?.map(|n| n as u64),464+ })465+ })?466+ .collect::<rusqlite::Result<Vec<_>>>()?;467+ Ok(rows)468+ }469+470+ pub fn active_machine_credential_count(&self, account: AccountId) -> Result<usize> {471+ self.conn472+ .query_row(473+ "SELECT COUNT(*) FROM machine_credentials WHERE account_id = ?1",474+ params![account.to_string()],475+ |row| row.get::<_, i64>(0),476+ )477+ .map(|n| n as usize)478+ .map_err(Into::into)479+ }480+481+ pub fn revoke_machine_credential(482+ &self,483+ account: AccountId,484+ id: &str,485+ _now: u64,486+ ) -> Result<bool> {487+ let changed = self.conn.execute(488+ "DELETE FROM machine_credentials WHERE id = ?1 AND account_id = ?2",489+ params![id, account.to_string()],490+ )?;491+ Ok(changed > 0)492+ }493+297494 // -- resources --------------------------------------------------------------298495 496+ pub fn resource_count(&self, owner: AccountId) -> Result<usize> {497+ self.conn498+ .query_row(499+ "SELECT COUNT(*) FROM resources WHERE owner_id = ?1",500+ params![owner.to_string()],501+ |row| row.get::<_, i64>(0),502+ )503+ .map(|n| n as usize)504+ .map_err(Into::into)505+ }506+507+ pub fn resource_named(&self, owner: AccountId, name: &str, kind: ResourceKind) -> Result<bool> {508+ self.conn509+ .query_row(510+ "SELECT EXISTS(511+ SELECT 1 FROM resources512+ WHERE owner_id = ?1 AND name = ?2 AND kind = ?3513+ )",514+ params![owner.to_string(), name, kind.as_str()],515+ |row| row.get(0),516+ )517+ .map_err(Into::into)518+ }519+299520 /// Create a resource, or update the one that already has this owner, name and kind.300521 ///301522 /// Re-running `devsite expose --name Hermes` is an ordinary thing to do — to change@@ -304,7 +525,7 @@ impl Db {
304525 /// stable also means capabilities already issued for it stay meaningful.305526 306527 pub fn create_resource(307- &self,528+ &mut self,308529 owner: AccountId,309530 name: &str,310531 kind: ResourceKind,@@ -313,21 +534,38 @@ impl Db {
313534 folder: Option<&str>,314535 now: u64,315536 ) -> Result<ResourceId> {316- let existing: Option<String> = self537+ let existing: Option<(String, Option<String>)> = self317538 .conn318539 .query_row(319- "SELECT id FROM resources WHERE owner_id = ?1 AND name = ?2 AND kind = ?3",540+ "SELECT id, url FROM resources WHERE owner_id = ?1 AND name = ?2 AND kind = ?3",320541 params![owner.to_string(), name, kind.as_str()],321- |row| row.get(0),542+ |row| Ok((row.get(0)?, row.get(1)?)),322543 )323544 .optional()?;324545 325- if let Some(id) = existing {546+ if let Some((id, previous_url)) = existing {326547 let id = ResourceId::from_str(&id)?;327- self.conn.execute(548+ let tx = self.conn.transaction()?;549+ tx.execute(328550 "UPDATE resources SET visibility = ?1, url = ?2, folder = ?3 WHERE id = ?4",329551 params![visibility.as_str(), url, folder, id.to_string()],330552 )?;553+ if previous_url.as_deref() != url {554+ // Approval applies to the link the recipient inspected. An555+ // owner cannot swap its destination underneath an accepted row.556+ tx.execute(557+ "INSERT OR IGNORE INTO share_invitations558+ (resource_id, viewer_id, status)559+ SELECT resource_id, viewer_id, 'pending' FROM shares560+ WHERE resource_id = ?1",561+ params![id.to_string()],562+ )?;563+ tx.execute(564+ "DELETE FROM shares WHERE resource_id = ?1",565+ params![id.to_string()],566+ )?;567+ }568+ tx.commit()?;331569 return Ok(id);332570 }333571 @@ -371,7 +609,7 @@ impl Db {
371609 rows.into_iter().collect()372610 }373611 374- /// Resources belonging to other people that were explicitly shared with `viewer`.612+ /// Accepted resources belonging to other people that were shared with `viewer`.375613 pub fn resources_shared_with(&self, viewer: AccountId) -> Result<Vec<Resource>> {376614 let mut stmt = self.conn.prepare(377615 "SELECT r.id, r.owner_id, r.name, r.kind, r.visibility, r.url, r.folder@@ -386,21 +624,49 @@ impl Db {
386624 rows.into_iter().collect()387625 }388626 389- /// Set exactly who a resource is shared with, replacing whoever was there.627+ /// Set exactly who is invited to a resource, replacing whoever was there.390628 ///391629 /// Replacing rather than adding is the whole point. `devsite expose --share392- /// @carol` reads as "this is Carol's now", and an accumulating list would393- /// leave everyone previously named still holding access — a permission that394- /// could be granted and never taken back. Sharing with nobody revokes.630+ /// @carol` reads as "offer this to Carol now", and an accumulating list631+ /// would leave everyone previously named still holding access. Existing632+ /// decisions are preserved; a routine sync cannot undo a recipient's decline.395633 pub fn set_shares(&mut self, resource: ResourceId, viewers: &[AccountId]) -> Result<()> {396634 let tx = self.conn.transaction()?;397- tx.execute(398- "DELETE FROM shares WHERE resource_id = ?1",399- params![resource.to_string()],400- )?;635+ let existing = {636+ let mut stmt = tx.prepare(637+ "SELECT viewer_id FROM shares WHERE resource_id = ?1638+ UNION639+ SELECT viewer_id FROM share_invitations WHERE resource_id = ?1",640+ )?;641+ let rows = stmt642+ .query_map(params![resource.to_string()], |row| row.get::<_, String>(0))?643+ .collect::<rusqlite::Result<Vec<_>>>()?;644+ rows645+ };646+ let desired = viewers647+ .iter()648+ .map(ToString::to_string)649+ .collect::<std::collections::HashSet<_>>();650+ for viewer in existing {651+ if !desired.contains(&viewer) {652+ tx.execute(653+ "DELETE FROM shares WHERE resource_id = ?1 AND viewer_id = ?2",654+ params![resource.to_string(), &viewer],655+ )?;656+ tx.execute(657+ "DELETE FROM share_invitations658+ WHERE resource_id = ?1 AND viewer_id = ?2",659+ params![resource.to_string(), &viewer],660+ )?;661+ }662+ }401663 for viewer in viewers {402664 tx.execute(403- "INSERT OR IGNORE INTO shares (resource_id, viewer_id) VALUES (?1, ?2)",665+ "INSERT OR IGNORE INTO share_invitations (resource_id, viewer_id, status)666+ SELECT ?1, ?2, 'pending'667+ WHERE NOT EXISTS (668+ SELECT 1 FROM shares WHERE resource_id = ?1 AND viewer_id = ?2669+ )",404670 params![resource.to_string(), viewer.to_string()],405671 )?;406672 }@@ -437,6 +703,111 @@ impl Db {
437703 .collect()438704 }439705 706+ pub fn share_recipients(&self, resource: ResourceId) -> Result<Vec<ShareRecipient>> {707+ let mut stmt = self.conn.prepare(708+ "SELECT a.handle, recipients.status709+ FROM (710+ SELECT viewer_id, 'accepted' AS status711+ FROM shares WHERE resource_id = ?1712+ UNION ALL713+ SELECT viewer_id, status714+ FROM share_invitations WHERE resource_id = ?1715+ ) recipients716+ JOIN accounts a ON a.id = recipients.viewer_id717+ WHERE a.handle IS NOT NULL718+ ORDER BY a.handle COLLATE NOCASE",719+ )?;720+ let rows = stmt721+ .query_map(params![resource.to_string()], |row| {722+ Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))723+ })?724+ .collect::<rusqlite::Result<Vec<_>>>()?;725+ rows.into_iter()726+ .map(|(handle, status)| {727+ Ok(ShareRecipient {728+ handle,729+ status: ShareStatus::parse(&status)?,730+ })731+ })732+ .collect()733+ }734+735+ pub fn incoming_shares(&self, viewer: AccountId) -> Result<Vec<IncomingShare>> {736+ let mut stmt = self.conn.prepare(737+ "SELECT r.id, r.owner_id, r.name, r.kind, r.visibility, r.url, r.folder,738+ a.handle, incoming.status739+ FROM (740+ SELECT resource_id, 'accepted' AS status741+ FROM shares WHERE viewer_id = ?1742+ UNION ALL743+ SELECT resource_id, status744+ FROM share_invitations745+ WHERE viewer_id = ?1 AND status = 'pending'746+ ) incoming747+ JOIN resources r ON r.id = incoming.resource_id748+ JOIN accounts a ON a.id = r.owner_id749+ WHERE r.visibility = 'shared' AND a.handle IS NOT NULL750+ ORDER BY CASE incoming.status WHEN 'pending' THEN 0 ELSE 1 END, r.created_at",751+ )?;752+ let rows = stmt753+ .query_map(params![viewer.to_string()], |row| {754+ let resource = row_to_resource(row)?;755+ let owner_handle = row.get::<_, String>(7)?;756+ let status = row.get::<_, String>(8)?;757+ Ok((resource, owner_handle, status))758+ })?759+ .collect::<rusqlite::Result<Vec<_>>>()?;760+ rows.into_iter()761+ .map(|(resource, owner_handle, status)| {762+ Ok(IncomingShare {763+ resource: resource?,764+ owner_handle,765+ status: ShareStatus::parse(&status)?,766+ })767+ })768+ .collect()769+ }770+771+ pub fn accept_share(&mut self, viewer: AccountId, resource: ResourceId) -> Result<bool> {772+ let tx = self.conn.transaction()?;773+ let changed = tx.execute(774+ "DELETE FROM share_invitations775+ WHERE resource_id = ?1 AND viewer_id = ?2 AND status = 'pending'",776+ params![resource.to_string(), viewer.to_string()],777+ )?;778+ if changed > 0 {779+ tx.execute(780+ "INSERT OR IGNORE INTO shares (resource_id, viewer_id) VALUES (?1, ?2)",781+ params![resource.to_string(), viewer.to_string()],782+ )?;783+ }784+ tx.commit()?;785+ Ok(changed > 0)786+ }787+788+ pub fn decline_share(&mut self, viewer: AccountId, resource: ResourceId) -> Result<bool> {789+ let tx = self.conn.transaction()?;790+ let accepted = tx.execute(791+ "DELETE FROM shares WHERE resource_id = ?1 AND viewer_id = ?2",792+ params![resource.to_string(), viewer.to_string()],793+ )?;794+ let pending = tx.execute(795+ "UPDATE share_invitations SET status = 'declined'796+ WHERE resource_id = ?1 AND viewer_id = ?2 AND status = 'pending'",797+ params![resource.to_string(), viewer.to_string()],798+ )?;799+ if accepted > 0 {800+ tx.execute(801+ "INSERT INTO share_invitations (resource_id, viewer_id, status)802+ VALUES (?1, ?2, 'declined')803+ ON CONFLICT(resource_id, viewer_id) DO UPDATE SET status = 'declined'",804+ params![resource.to_string(), viewer.to_string()],805+ )?;806+ }807+ tx.commit()?;808+ Ok(accepted > 0 || pending > 0)809+ }810+440811 // -- profiles ---------------------------------------------------------------441812 442813 /// The account's stored theme, as the canonical declaration text written by@@ -467,6 +838,138 @@ impl Db {
467838 Ok(())468839 }469840 841+ pub fn profile_private_only(&self, account: AccountId) -> Result<bool> {842+ self.conn843+ .query_row(844+ "SELECT private_only FROM profiles WHERE account_id = ?1",845+ params![account.to_string()],846+ |row| row.get::<_, bool>(0),847+ )848+ .optional()849+ .map(|value| value.unwrap_or(false))850+ .map_err(Into::into)851+ }852+853+ pub fn set_profile_private_only(&self, account: AccountId, private_only: bool) -> Result<()> {854+ self.conn.execute(855+ "INSERT INTO profiles (account_id, private_only) VALUES (?1, ?2)856+ ON CONFLICT(account_id) DO UPDATE SET private_only = excluded.private_only",857+ params![account.to_string(), private_only],858+ )?;859+ Ok(())860+ }861+862+ // -- connection tickets ---------------------------------------------------863+864+ pub fn create_connection_ticket(865+ &self,866+ token_hash: &str,867+ viewer: AccountId,868+ resource: ResourceId,869+ now: u64,870+ expires_at: u64,871+ ) -> Result<()> {872+ self.conn.execute(873+ "DELETE FROM connection_tickets WHERE expires_at <= ?1",874+ params![now as i64],875+ )?;876+ self.conn.execute(877+ "INSERT INTO connection_tickets878+ (token_hash, viewer_id, resource_id, expires_at)879+ VALUES (?1, ?2, ?3, ?4)",880+ params![881+ token_hash,882+ viewer.to_string(),883+ resource.to_string(),884+ expires_at as i64885+ ],886+ )?;887+ Ok(())888+ }889+890+ /// Atomically consume a ticket. `DELETE ... RETURNING` means concurrent redeemers891+ /// cannot both receive a tunnel session even if the database is later moved off the892+ /// process-wide mutex.893+ pub fn consume_connection_ticket(894+ &self,895+ token_hash: &str,896+ now: u64,897+ ) -> Result<Option<(AccountId, ResourceId)>> {898+ let row: Option<(String, String)> = self899+ .conn900+ .query_row(901+ "DELETE FROM connection_tickets902+ WHERE token_hash = ?1 AND expires_at > ?2903+ RETURNING viewer_id, resource_id",904+ params![token_hash, now as i64],905+ |row| Ok((row.get(0)?, row.get(1)?)),906+ )907+ .optional()?;908+ row.map(|(viewer, resource)| {909+ Ok((910+ AccountId::from_str(&viewer)?,911+ ResourceId::from_str(&resource)?,912+ ))913+ })914+ .transpose()915+ }916+917+ pub fn create_tunnel_session(918+ &self,919+ token_hash: &str,920+ viewer: AccountId,921+ resource: ResourceId,922+ client_endpoint_id: &str,923+ now: u64,924+ expires_at: u64,925+ ) -> Result<()> {926+ self.conn.execute(927+ "DELETE FROM tunnel_sessions WHERE expires_at <= ?1",928+ params![now as i64],929+ )?;930+ self.conn.execute(931+ "INSERT INTO tunnel_sessions932+ (token_hash, viewer_id, resource_id, client_endpoint_id, expires_at)933+ VALUES (?1, ?2, ?3, ?4, ?5)",934+ params![935+ token_hash,936+ viewer.to_string(),937+ resource.to_string(),938+ client_endpoint_id,939+ expires_at as i64940+ ],941+ )?;942+ Ok(())943+ }944+945+ pub fn tunnel_session(&self, token_hash: &str, now: u64) -> Result<Option<TunnelSession>> {946+ let row: Option<(String, String, String, i64)> = self947+ .conn948+ .query_row(949+ "SELECT viewer_id, resource_id, client_endpoint_id, expires_at950+ FROM tunnel_sessions WHERE token_hash = ?1 AND expires_at > ?2",951+ params![token_hash, now as i64],952+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),953+ )954+ .optional()?;955+ row.map(|(viewer, resource, client_endpoint_id, expires_at)| {956+ Ok(TunnelSession {957+ viewer_id: AccountId::from_str(&viewer)?,958+ resource_id: ResourceId::from_str(&resource)?,959+ client_endpoint_id,960+ expires_at: expires_at as u64,961+ })962+ })963+ .transpose()964+ }965+966+ pub fn delete_tunnel_session(&self, token_hash: &str) -> Result<bool> {967+ Ok(self.conn.execute(968+ "DELETE FROM tunnel_sessions WHERE token_hash = ?1",969+ params![token_hash],970+ )? > 0)971+ }972+470973 // -- daemon identity --------------------------------------------------------471974 472975 /// Record which endpoint id an account's daemon answers on.@@ -532,12 +1035,56 @@ mod tests {
5321035 (db, alice, bob)5331036 }5341037 1038+ 1039+ fn connection_tickets_are_single_use_and_sessions_are_client_bound() {1040+ let (mut db, alice, _) = seeded();1041+ let service = db1042+ .create_resource(1043+ alice.id,1044+ "shell",1045+ ResourceKind::Service,1046+ Visibility::Private,1047+ None,1048+ Some("Services"),1049+ 1,1050+ )1051+ .unwrap();1052+ db.create_connection_ticket("ticket-hash", alice.id, service, 0, 120)1053+ .unwrap();1054+1055+ assert_eq!(1056+ db.consume_connection_ticket("ticket-hash", 10).unwrap(),1057+ Some((alice.id, service))1058+ );1059+ assert_eq!(1060+ db.consume_connection_ticket("ticket-hash", 10).unwrap(),1061+ None1062+ );1063+1064+ db.create_tunnel_session("session-hash", alice.id, service, "client-key", 0, 1000)1065+ .unwrap();1066+ assert_eq!(1067+ db.tunnel_session("session-hash", 20).unwrap(),1068+ Some(TunnelSession {1069+ viewer_id: alice.id,1070+ resource_id: service,1071+ client_endpoint_id: "client-key".into(),1072+ expires_at: 1000,1073+ })1074+ );1075+ assert!(db.delete_tunnel_session("session-hash").unwrap());1076+ assert!(db.tunnel_session("session-hash", 20).unwrap().is_none());1077+ }1078+5351079 5361080 fn signing_in_twice_reuses_the_same_account() {5371081 let db = Db::open_in_memory().unwrap();5381082 let first = db.upsert_account("ps_alice", 0).unwrap();5391083 let second = db.upsert_account("ps_alice", 100).unwrap();540- assert_eq!(first.id, second.id, "a returning user must not get a new account");1084+ assert_eq!(1085+ first.id, second.id,1086+ "a returning user must not get a new account"1087+ );5411088 }5421089 5431090 @@ -550,13 +1097,29 @@ mod tests {
5501097 db.set_handle(carol.id, "carol").unwrap();5511098 5521099 let agent = db553- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)1100+ .create_resource(1101+ alice.id,1102+ "Agent",1103+ ResourceKind::Service,1104+ Visibility::Shared,1105+ None,1106+ None,1107+ 0,1108+ )5541109 .unwrap();5551110 5561111 db.set_shares(agent, &[bob.id]).unwrap();1112+ assert!(db.shared_with(agent).unwrap().is_empty());1113+ assert_eq!(1114+ db.share_recipients(agent).unwrap()[0].status,1115+ ShareStatus::Pending1116+ );1117+ assert!(db.accept_share(bob.id, agent).unwrap());5571118 assert_eq!(db.shared_with(agent).unwrap(), vec![bob.id]);5581119 5591120 db.set_shares(agent, &[carol.id]).unwrap();1121+ assert!(db.shared_with(agent).unwrap().is_empty());1122+ assert!(db.accept_share(carol.id, agent).unwrap());5601123 assert_eq!(5611124 db.shared_with(agent).unwrap(),5621125 vec![carol.id],@@ -569,29 +1132,134 @@ mod tests {
5691132 assert!(db.resources_shared_with(carol.id).unwrap().is_empty());5701133 }5711134 1135+ 1136+ fn a_recipient_controls_whether_a_share_reaches_their_profile() {1137+ let (mut db, alice, bob) = seeded();1138+ let agent = db1139+ .create_resource(1140+ alice.id,1141+ "Agent",1142+ ResourceKind::Service,1143+ Visibility::Shared,1144+ None,1145+ None,1146+ 0,1147+ )1148+ .unwrap();1149+1150+ db.set_shares(agent, &[bob.id]).unwrap();1151+ assert!(db.shared_with(agent).unwrap().is_empty());1152+ let incoming = db.incoming_shares(bob.id).unwrap();1153+ assert_eq!(incoming.len(), 1);1154+ assert_eq!(incoming[0].status, ShareStatus::Pending);1155+1156+ assert!(db.accept_share(bob.id, agent).unwrap());1157+ assert_eq!(db.shared_with(agent).unwrap(), vec![bob.id]);1158+ assert_eq!(db.resources_shared_with(bob.id).unwrap()[0].id, agent);1159+1160+ assert!(db.decline_share(bob.id, agent).unwrap());1161+ assert!(db.shared_with(agent).unwrap().is_empty());1162+ assert!(db.incoming_shares(bob.id).unwrap().is_empty());1163+ assert_eq!(1164+ db.share_recipients(agent).unwrap()[0].status,1165+ ShareStatus::Declined1166+ );1167+1168+ // A routine CLI sync naming Bob again preserves his decision instead1169+ // of turning the same rejected resource back into inbox spam.1170+ db.set_shares(agent, &[bob.id]).unwrap();1171+ assert_eq!(1172+ db.share_recipients(agent).unwrap()[0].status,1173+ ShareStatus::Declined1174+ );1175+ }1176+1177+ 1178+ fn changing_a_shared_link_destination_requires_fresh_approval() {1179+ let (mut db, alice, bob) = seeded();1180+ let link = db1181+ .create_resource(1182+ alice.id,1183+ "Docs",1184+ ResourceKind::Link,1185+ Visibility::Shared,1186+ Some("https://example.com/first"),1187+ None,1188+ 0,1189+ )1190+ .unwrap();1191+ db.set_shares(link, &[bob.id]).unwrap();1192+ assert!(db.accept_share(bob.id, link).unwrap());1193+1194+ let same = db1195+ .create_resource(1196+ alice.id,1197+ "Docs",1198+ ResourceKind::Link,1199+ Visibility::Shared,1200+ Some("https://example.com/second"),1201+ None,1202+ 10,1203+ )1204+ .unwrap();1205+ assert_eq!(same, link);1206+ assert!(db.shared_with(link).unwrap().is_empty());1207+ assert_eq!(1208+ db.share_recipients(link).unwrap()[0].status,1209+ ShareStatus::Pending1210+ );1211+ }1212+5721213 5731214 fn a_folder_is_set_replaced_and_cleared_by_naming_it_or_not() {5741215 // A folder has no independent existence — it is this string on however5751216 // many resources carry it — so re-adding without one takes the resource5761217 // out of it, the same way re-sharing without a handle revokes.577- let (db, alice, _) = seeded();1218+ let (mut db, alice, _) = seeded();5781219 5791220 let id = db580- .create_resource(alice.id, "klot.ski", ResourceKind::Link, Visibility::Public,581- Some("https://klot.ski"), Some("Games"), 0)1221+ .create_resource(1222+ alice.id,1223+ "klot.ski",1224+ ResourceKind::Link,1225+ Visibility::Public,1226+ Some("https://klot.ski"),1227+ Some("Games"),1228+ 0,1229+ )5821230 .unwrap();583- assert_eq!(db.resource(id).unwrap().unwrap().folder.as_deref(), Some("Games"));1231+ assert_eq!(1232+ db.resource(id).unwrap().unwrap().folder.as_deref(),1233+ Some("Games")1234+ );5841235 5851236 let same = db586- .create_resource(alice.id, "klot.ski", ResourceKind::Link, Visibility::Public,587- Some("https://klot.ski"), Some("Toys"), 0)1237+ .create_resource(1238+ alice.id,1239+ "klot.ski",1240+ ResourceKind::Link,1241+ Visibility::Public,1242+ Some("https://klot.ski"),1243+ Some("Toys"),1244+ 0,1245+ )5881246 .unwrap();5891247 assert_eq!(same, id, "re-adding must not make a second entry");590- assert_eq!(db.resource(id).unwrap().unwrap().folder.as_deref(), Some("Toys"));1248+ assert_eq!(1249+ db.resource(id).unwrap().unwrap().folder.as_deref(),1250+ Some("Toys")1251+ );5911252 592- db.create_resource(alice.id, "klot.ski", ResourceKind::Link, Visibility::Public,593- Some("https://klot.ski"), None, 0)594- .unwrap();1253+ db.create_resource(1254+ alice.id,1255+ "klot.ski",1256+ ResourceKind::Link,1257+ Visibility::Public,1258+ Some("https://klot.ski"),1259+ None,1260+ 0,1261+ )1262+ .unwrap();5951263 assert!(5961264 db.resource(id).unwrap().unwrap().folder.is_none(),5971265 "naming no folder should take it out of the one it was in"@@ -602,7 +1270,15 @@ mod tests {
6021270 fn deleting_a_resource_takes_its_shares_with_it() {6031271 let (mut db, alice, bob) = seeded();6041272 let agent = db605- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)1273+ .create_resource(1274+ alice.id,1275+ "Agent",1276+ ResourceKind::Service,1277+ Visibility::Shared,1278+ None,1279+ None,1280+ 0,1281+ )6061282 .unwrap();6071283 db.set_shares(agent, &[bob.id]).unwrap();6081284 @@ -621,7 +1297,15 @@ mod tests {
6211297 fn you_can_only_delete_your_own_resources() {6221298 let (mut db, alice, bob) = seeded();6231299 let hermes = db624- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Shared, None, None, 0)1300+ .create_resource(1301+ alice.id,1302+ "Hermes",1303+ ResourceKind::Service,1304+ Visibility::Shared,1305+ None,1306+ None,1307+ 0,1308+ )6251309 .unwrap();6261310 // Shared with Bob, so he can see it — which must not extend to removing it.6271311 db.set_shares(hermes, &[bob.id]).unwrap();@@ -634,12 +1318,29 @@ mod tests {
6341318 fn shares_are_visible_to_the_named_viewer_only() {6351319 let (mut db, alice, bob) = seeded();6361320 let agent = db637- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)1321+ .create_resource(1322+ alice.id,1323+ "Agent",1324+ ResourceKind::Service,1325+ Visibility::Shared,1326+ None,1327+ None,1328+ 0,1329+ )6381330 .unwrap();6391331 let hermes = db640- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Private, None, None, 0)1332+ .create_resource(1333+ alice.id,1334+ "Hermes",1335+ ResourceKind::Service,1336+ Visibility::Private,1337+ None,1338+ None,1339+ 0,1340+ )6411341 .unwrap();6421342 db.set_shares(agent, &[bob.id]).unwrap();1343+ assert!(db.accept_share(bob.id, agent).unwrap());6431344 6441345 let bobs = db.resources_shared_with(bob.id).unwrap();6451346 assert_eq!(bobs.len(), 1);@@ -654,7 +1355,15 @@ mod tests {
6541355 fn owners_do_not_see_their_own_resources_as_shared_with_them() {6551356 let (mut db, alice, bob) = seeded();6561357 let agent = db657- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)1358+ .create_resource(1359+ alice.id,1360+ "Agent",1361+ ResourceKind::Service,1362+ Visibility::Shared,1363+ None,1364+ None,1365+ 0,1366+ )6581367 .unwrap();6591368 db.set_shares(agent, &[bob.id, alice.id]).unwrap();6601369 assert!(@@ -672,6 +1381,18 @@ mod tests {
6721381 assert!(db.session_account("hash", 500).unwrap().is_none());6731382 }6741383 1384+ 1385+ fn logging_out_deletes_only_that_session() {1386+ let (db, alice, _) = seeded();1387+ db.create_session("browser-one", alice.id, 100).unwrap();1388+ db.create_session("browser-two", alice.id, 100).unwrap();1389+1390+ assert!(db.delete_session("browser-one").unwrap());1391+ assert!(db.session_account("browser-one", 50).unwrap().is_none());1392+ assert!(db.session_account("browser-two", 50).unwrap().is_some());1393+ assert!(!db.delete_session("browser-one").unwrap());1394+ }1395+6751396 6761397 fn registering_a_daemon_is_idempotent_and_survives_re_registration() {6771398 let (db, alice, _) = seeded();@@ -679,12 +1400,18 @@ mod tests {
6791400 6801401 db.register_daemon(alice.id, "abc").unwrap();6811402 db.register_daemon(alice.id, "abc").unwrap();682- assert_eq!(db.daemon_endpoint(alice.id).unwrap().as_deref(), Some("abc"));1403+ assert_eq!(1404+ db.daemon_endpoint(alice.id).unwrap().as_deref(),1405+ Some("abc")1406+ );6831407 6841408 // A new identity file means a new endpoint id, and the account should6851409 // follow it rather than keeping an id nothing answers on.6861410 db.register_daemon(alice.id, "def").unwrap();687- assert_eq!(db.daemon_endpoint(alice.id).unwrap().as_deref(), Some("def"));1411+ assert_eq!(1412+ db.daemon_endpoint(alice.id).unwrap().as_deref(),1413+ Some("def")1414+ );6881415 }6891416 6901417 @@ -709,6 +1436,8 @@ mod tests {
7091436 "the legacy database should look unmigrated"7101437 );7111438 let legacy_account = AccountId::generate().to_string();1439+ let legacy_bob = AccountId::generate().to_string();1440+ let legacy_resource = ResourceId::generate().to_string();7121441 legacy7131442 .execute(7141443 "INSERT INTO accounts (id, external_sub, handle, created_at)@@ -723,6 +1452,27 @@ mod tests {
7231452 params![legacy_account],7241453 )7251454 .unwrap();1455+ legacy1456+ .execute(1457+ "INSERT INTO accounts (id, external_sub, handle, created_at)1458+ VALUES (?1, 'ps_bob', 'bob', 0)",1459+ params![legacy_bob],1460+ )1461+ .unwrap();1462+ legacy1463+ .execute(1464+ "INSERT INTO resources1465+ (id, owner_id, name, kind, visibility, url, created_at)1466+ VALUES (?1, ?2, 'Agent', 'service', 'shared', NULL, 0)",1467+ params![legacy_resource, legacy_account],1468+ )1469+ .unwrap();1470+ legacy1471+ .execute(1472+ "INSERT INTO shares (resource_id, viewer_id) VALUES (?1, ?2)",1473+ params![legacy_resource, legacy_bob],1474+ )1475+ .unwrap();7261476 drop(legacy);7271477 7281478 let db = Db::open(file.to_str().unwrap()).unwrap();@@ -730,7 +1480,17 @@ mod tests {
7301480 7311481 // The identity survives the columns around it being dropped: an existing7321482 // daemon keeps working without re-registering.733- assert_eq!(db.daemon_endpoint(alice.id).unwrap().as_deref(), Some("abc"));1483+ assert_eq!(1484+ db.daemon_endpoint(alice.id).unwrap().as_deref(),1485+ Some("abc")1486+ );1487+ let bob = db.account_by_handle("bob").unwrap().unwrap();1488+ let resource = ResourceId::from_str(&legacy_resource).unwrap();1489+ assert_eq!(1490+ db.shared_with(resource).unwrap(),1491+ vec![bob.id],1492+ "a share accepted before invitations existed must stay accepted"1493+ );7341494 7351495 std::fs::remove_file(&file).ok();7361496 }@@ -740,28 +1500,64 @@ mod tests {
7401500 // `devsite expose --name Hermes` run twice must not leave two Hermes entries on7411501 // the profile, and the id must survive so already-issued capabilities still name7421502 // the same thing.743- let (db, alice, _) = seeded();1503+ let (mut db, alice, _) = seeded();7441504 let first = db745- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Private, None, None, 0)1505+ .create_resource(1506+ alice.id,1507+ "Hermes",1508+ ResourceKind::Service,1509+ Visibility::Private,1510+ None,1511+ None,1512+ 0,1513+ )7461514 .unwrap();7471515 let second = db748- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Shared, None, None, 10)1516+ .create_resource(1517+ alice.id,1518+ "Hermes",1519+ ResourceKind::Service,1520+ Visibility::Shared,1521+ None,1522+ None,1523+ 10,1524+ )7491525 .unwrap();7501526 7511527 assert_eq!(first, second, "re-exposing must reuse the resource id");7521528 let owned = db.resources_owned_by(alice.id).unwrap();7531529 assert_eq!(owned.len(), 1, "expected exactly one Hermes, got {owned:?}");754- assert_eq!(owned[0].visibility, Visibility::Shared, "visibility should update");1530+ assert_eq!(1531+ owned[0].visibility,1532+ Visibility::Shared,1533+ "visibility should update"1534+ );7551535 }7561536 7571537 7581538 fn different_people_may_use_the_same_service_name() {759- let (db, alice, bob) = seeded();1539+ let (mut db, alice, bob) = seeded();7601540 let a = db761- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Private, None, None, 0)1541+ .create_resource(1542+ alice.id,1543+ "Agent",1544+ ResourceKind::Service,1545+ Visibility::Private,1546+ None,1547+ None,1548+ 0,1549+ )7621550 .unwrap();7631551 let b = db764- .create_resource(bob.id, "Agent", ResourceKind::Service, Visibility::Private, None, None, 0)1552+ .create_resource(1553+ bob.id,1554+ "Agent",1555+ ResourceKind::Service,1556+ Visibility::Private,1557+ None,1558+ None,1559+ 0,1560+ )7651561 .unwrap();7661562 assert_ne!(a, b, "names are scoped to their owner");7671563 }@@ -773,12 +1569,23 @@ mod tests {
7731569 // it. A share pointing at a deleted resource would have been left behind.7741570 let (mut db, alice, bob) = seeded();7751571 let agent = db776- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)1572+ .create_resource(1573+ alice.id,1574+ "Agent",1575+ ResourceKind::Service,1576+ Visibility::Shared,1577+ None,1578+ None,1579+ 0,1580+ )7771581 .unwrap();7781582 db.set_shares(agent, &[bob.id]).unwrap();7791583 7801584 db.conn781- .execute("DELETE FROM resources WHERE id = ?1", params![agent.to_string()])1585+ .execute(1586+ "DELETE FROM resources WHERE id = ?1",1587+ params![agent.to_string()],1588+ )7821589 .unwrap();7831590 7841591 assert!(@@ -792,7 +1599,8 @@ mod tests {
7921599 let (db, alice, _) = seeded();7931600 assert!(db.custom_css(alice.id).unwrap().is_none());7941601 795- db.set_custom_css(alice.id, Some("--pico-primary: #7b3fe4;\n")).unwrap();1602+ db.set_custom_css(alice.id, Some("--pico-primary: #7b3fe4;\n"))1603+ .unwrap();7961604 assert_eq!(7971605 db.custom_css(alice.id).unwrap().as_deref(),7981606 Some("--pico-primary: #7b3fe4;\n")@@ -802,6 +1610,42 @@ mod tests {
8021610 assert!(db.custom_css(alice.id).unwrap().is_none());8031611 }8041612 1613+ 1614+ fn private_only_is_explicit_and_reversible() {1615+ let (db, alice, _) = seeded();1616+ assert!(!db.profile_private_only(alice.id).unwrap());1617+1618+ db.set_profile_private_only(alice.id, true).unwrap();1619+ assert!(db.profile_private_only(alice.id).unwrap());1620+1621+ db.set_profile_private_only(alice.id, false).unwrap();1622+ assert!(!db.profile_private_only(alice.id).unwrap());1623+ }1624+1625+ 1626+ fn machine_credentials_are_named_resolvable_and_revocable() {1627+ let (db, alice, _) = seeded();1628+ db.create_machine_credential("machine_one", alice.id, "Laptop", "hash", 10)1629+ .unwrap();1630+1631+ assert_eq!(db.active_machine_credential_count(alice.id).unwrap(), 1);1632+ assert_eq!(1633+ db.machine_account("hash", 20).unwrap().unwrap().id,1634+ alice.id1635+ );1636+ let credentials = db.machine_credentials(alice.id).unwrap();1637+ assert_eq!(credentials.len(), 1);1638+ assert_eq!(credentials[0].name, "Laptop");1639+ assert_eq!(credentials[0].last_used_at, Some(20));1640+1641+ assert!(db1642+ .revoke_machine_credential(alice.id, "machine_one", 30)1643+ .unwrap());1644+ assert!(db.machine_account("hash", 40).unwrap().is_none());1645+ assert_eq!(db.active_machine_credential_count(alice.id).unwrap(), 0);1646+ assert!(db.machine_credentials(alice.id).unwrap().is_empty());1647+ }1648+8051649 8061650 fn handles_are_matched_case_insensitively() {8071651 let (db, alice, _) = seeded();@@ -48,7 +48,8 @@ impl Issuer {
4848 pub fn load_or_create(state_dir: &Path, issuer_name: &str) -> Result<Self> {4949 let path = state_dir.join("capability_signing.key");5050 let key = if path.exists() {51- let raw = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;51+ let raw =52+ std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;5253 let bytes: [u8; 32] = raw5354 .as_slice()5455 .try_into()@@ -58,7 +59,10 @@ impl Issuer {
5859 std::fs::create_dir_all(state_dir)?;5960 let key = SigningKey::generate(&mut rand::rngs::OsRng);6061 write_private(&path, &key.to_bytes())?;61- tracing::info!("generated a new capability signing key at {}", path.display());62+ tracing::info!(63+ "generated a new capability signing key at {}",64+ path.display()65+ );6266 key6367 };6468 Ok(Self {@@ -77,14 +81,14 @@ impl Issuer {
7781 }7882 7983 /// Mint a grant. Every field is supplied by the caller from verified state — nothing80- /// here comes from the browser except `browser_key`, which is exactly what the daemon84+ /// here comes from the client except `client_key`, which is exactly what the daemon8185 /// will check the connection against.8286 pub fn issue(8387 &self,8488 viewer: AccountId,8589 resource: ResourceId,8690 audience: KeyBytes,87- browser_key: KeyBytes,91+ client_key: KeyBytes,8892 now: u64,8993 ) -> Result<SignedCapability> {9094 let mut nonce = [0u8; 16];@@ -97,8 +101,8 @@ impl Issuer {
97101 viewer,98102 resource,99103 audience,100- browser_key,101- permission: Permission::HttpRead,104+ client_key,105+ permission: Permission::TcpConnect,102106 issued_at: now,103107 expires_at: now + DEFAULT_LIFETIME_SECS,104108 nonce,@@ -119,7 +123,7 @@ fn write_private(path: &PathBuf, contents: &[u8]) -> Result<()> {
119123 .mode(0o600)120124 .open(path)?;121125 file.write_all(contents)?;122- return Ok(());126+ Ok(())123127 }124128 125129 {@@ -151,7 +155,12 @@ mod tests {
151155 .verify_for(&issuer.verifying_key(), &[7; 32], &[8; 32], 1000)152156 .is_ok());153157 assert!(cap154- .verify_for(&issuer.verifying_key(), &[7; 32], &[8; 32], 1000 + DEFAULT_LIFETIME_SECS + 1)158+ .verify_for(159+ &issuer.verifying_key(),160+ &[7; 32],161+ &[8; 32],162+ 1000 + DEFAULT_LIFETIME_SECS + 1163+ )155164 .is_err());156165 157166 std::fs::remove_dir_all(&dir).ok();@@ -21,7 +21,7 @@ use tower_http::services::{ServeDir, ServeFile};
2121 use tower_http::set_header::SetResponseHeaderLayer;2222 use tower_http::trace::TraceLayer;2323 24-use crate::api::AppState;24+use crate::api::{AppState, RateLimits};2525 use crate::auth::ShooVerifier;2626 use crate::db::Db;2727 use crate::issuer::Issuer;@@ -56,7 +56,9 @@ impl Config {
5656 web_root: std::env::var("DEVSITE_WEB_ROOT")5757 .unwrap_or_else(|_| "web".to_string())5858 .into(),59- signing_key: std::env::var("DEVSITE_SIGNING_KEY").ok().filter(|k| !k.trim().is_empty()),59+ signing_key: std::env::var("DEVSITE_SIGNING_KEY")60+ .ok()61+ .filter(|k| !k.trim().is_empty()),6062 })6163 }6264 @@ -91,7 +93,9 @@ async fn main() -> Result<()> {
9193 // to exercise the stack without a round trip through Google).9294 let args: Vec<String> = std::env::args().skip(1).collect();9395 if args.first().map(String::as_str) == Some("issue-session") {94- let handle = args.get(1).context("usage: devsite-server issue-session <handle>")?;96+ let handle = args97+ .get(1)98+ .context("usage: devsite-server issue-session <handle>")?;9599 return issue_session(&config, handle);96100 }97101 @@ -108,6 +112,7 @@ async fn main() -> Result<()> {
108112 109113 let state = Arc::new(AppState {110114 db: Mutex::new(db),115+ rate_limits: Mutex::new(RateLimits::default()),111116 issuer,112117 shoo,113118 public_origin: config.public_origin.clone(),@@ -139,30 +144,21 @@ async fn main() -> Result<()> {
139144 Ok(())140145 }141146 142-/// Static assets, with a cache policy that matches how they are versioned.143-///144-/// `/pkg/<hash>/*` is content-addressed by `scripts/build-wasm.sh`, so it can be cached145-/// forever — a new build lands on a new path. Everything else, including the manifest that146-/// names the current hash, must revalidate, or a deploy would leave browsers pinned to the147-/// previous bundle.147+/// Static assets always revalidate. They are small and deployed with the server binary.148148 fn static_assets(web_root: &std::path::Path, index: &std::path::Path) -> axum::routing::Router {149149 let files = ServeDir::new(web_root).not_found_service(ServeFile::new(index));150150 151151 axum::routing::Router::new()152152 .fallback_service(files)153- .layer(axum::middleware::from_fn(|req: axum::extract::Request, next: axum::middleware::Next| async move {154- let path = req.uri().path().to_string();155- let mut response = next.run(req).await;156- let policy = if path.starts_with("/pkg/") && !path.ends_with("manifest.json") {157- "public, max-age=31536000, immutable"158- } else {159- "no-cache"160- };161- response162- .headers_mut()163- .insert(header::CACHE_CONTROL, HeaderValue::from_static(policy));164- response165- }))153+ .layer(axum::middleware::from_fn(154+ |req: axum::extract::Request, next: axum::middleware::Next| async move {155+ let mut response = next.run(req).await;156+ response157+ .headers_mut()158+ .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));159+ response160+ },161+ ))166162 }167163 168164 /// Serve the SPA shell for client-side routes.@@ -174,15 +170,11 @@ fn static_assets(web_root: &std::path::Path, index: &std::path::Path) -> axum::r
174170 /// `Router::layer` only wraps routes registered before it, so these two sit175171 /// between the API's `no-store` layer and the static fallback's own headers, and176172 /// inherit neither. A shell with no directives and no validator is one a browser177-/// may cache heuristically — and this shell names the stylesheet and the script178-/// for the build that produced it, so a stale copy pins a visitor to assets that179-/// a later deploy has renamed or deleted. `/` never had the problem; `/@handle`180-/// silently did.173+/// may cache heuristically. `/` never had the problem; client-side routes silently did.181174 fn serve_index(path: PathBuf) -> impl Fn() -> std::future::Ready<Response> + Clone {182175 move || {183- let body = std::fs::read_to_string(&path).unwrap_or_else(|_| {184- "<!doctype html><p>web/index.html is missing.".to_string()185- });176+ let body = std::fs::read_to_string(&path)177+ .unwrap_or_else(|_| "<!doctype html><p>web/index.html is missing.".to_string());186178 let mut response = Html(body).into_response();187179 response188180 .headers_mut()@@ -191,26 +183,6 @@ fn serve_index(path: PathBuf) -> impl Fn() -> std::future::Ready<Response> + Clo
191183 }192184 }193185 194-195-mod tests {196- use super::*;197-198- 199- fn the_spa_shell_is_never_cached_without_revalidating() {200- let index = std::env::temp_dir().join(format!("devsite-index-{}.html", std::process::id()));201- std::fs::write(&index, "<!doctype html><title>x</title>").unwrap();202-203- let response = serve_index(index.clone())().into_inner();204- assert_eq!(205- response.headers().get(header::CACHE_CONTROL).unwrap(),206- "no-cache",207- "a cached shell pins a browser to assets a later deploy may have removed"208- );209-210- std::fs::remove_file(&index).ok();211- }212-}213-214186 /// Mint a session for `handle`, creating the account if it does not exist.215187 ///216188 /// The account is keyed on a `local:` subject so it can never collide with a Shoo pairwise@@ -249,3 +221,23 @@ async fn shutdown() {
249221 let _ = tokio::signal::ctrl_c().await;250222 tracing::info!("shutting down");251223 }224+225+226+mod tests {227+ use super::*;228+229+ 230+ fn the_spa_shell_is_never_cached_without_revalidating() {231+ let index = std::env::temp_dir().join(format!("devsite-index-{}.html", std::process::id()));232+ std::fs::write(&index, "<!doctype html><title>x</title>").unwrap();233+234+ let response = serve_index(index.clone())().into_inner();235+ assert_eq!(236+ response.headers().get(header::CACHE_CONTROL).unwrap(),237+ "no-cache",238+ "a cached shell pins a browser to assets a later deploy may have removed"239+ );240+241+ std::fs::remove_file(&index).ok();242+ }243+}@@ -1,6 +1,6 @@
11 //! The one place that decides who may see what.22 //!3-//! Both the profile listing and capability issuance route through [`can_view`]. Keeping it3+//! The profile, ticket, and capability paths all route through [`can_view`]. Keeping it44 //! single means the two can never disagree — a resource hidden from Bob's view of a55 //! profile is also a resource he cannot obtain a capability for.66 @@ -118,7 +118,10 @@ const PROPERTIES: &[(&str, Kind)] = &[
118118 // The typeface is fixed: Open Sans, at one of its two weights. There is no119119 // --pico-font-family here, and that is deliberate.120120 ("--pico-font-weight", Kind::Keyword(&["400", "700"])),121- ("--pico-text-decoration", Kind::Keyword(&["none", "underline"])),121+ (122+ "--pico-text-decoration",123+ Kind::Keyword(&["none", "underline"]),124+ ),122125 ];123126 124127 /// Read a theme, or say precisely what is wrong with it.@@ -145,7 +148,9 @@ pub fn parse(input: &str) -> Result<Vec<Declaration>, String> {
145148 ));146149 }147150 if chunk.contains('!') {148- return Err("`!important` is not allowed; a theme never has to out-rank anything".into());151+ return Err(152+ "`!important` is not allowed; a theme never has to out-rank anything".into(),153+ );149154 }150155 151156 let (name, value) = chunk@@ -193,7 +198,9 @@ pub fn to_css(declarations: &[Declaration]) -> String {
193198 194199 /// Every property a theme may set, for `devsite theme properties` and the docs.195200 pub fn properties() -> impl Iterator<Item = (&'static str, String)> {196- PROPERTIES.iter().map(|(name, kind)| (*name, kind.describe()))201+ PROPERTIES202+ .iter()203+ .map(|(name, kind)| (*name, kind.describe()))197204 }198205 199206 fn unknown_property(name: &str) -> String {@@ -211,7 +218,9 @@ fn unknown_property(name: &str) -> String {
211218 212219 match closest {213220 Some(known) => format!("`{name}` is not a theme property — did you mean `{known}`?"),214- None => format!("`{name}` is not a theme property; run `devsite theme properties` for the list"),221+ None => {222+ format!("`{name}` is not a theme property; run `devsite theme properties` for the list")223+ }215224 }216225 }217226 @@ -265,7 +274,9 @@ fn is_length(value: &str) -> bool {
265274 if value == "0" {266275 return true;267276 }268- const UNITS: &[&str] = &["px", "rem", "em", "ch", "ex", "vw", "vh", "vmin", "vmax", "%"];277+ const UNITS: &[&str] = &[278+ "px", "rem", "em", "ch", "ex", "vw", "vh", "vmin", "vmax", "%",279+ ];269280 UNITS.iter().any(|unit| {270281 value271282 .strip_suffix(unit)@@ -303,28 +314,154 @@ fn normalize_whitespace(value: &str) -> String {
303314 304315 /// The CSS named colours, sorted for `binary_search`.305316 const NAMED_COLORS: &[&str] = &[306- "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black",307- "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse",308- "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan",309- "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta",310- "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen",311- "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink",312- "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen",313- "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow",314- "grey", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender",315- "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan",316- "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink", "lightsalmon",317- "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue",318- "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine",319- "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue",320- "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream",321- "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange",322- "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred",323- "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple",324- "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell",325- "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen",326- "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white",327- "whitesmoke", "yellow", "yellowgreen",317+ "aliceblue",318+ "antiquewhite",319+ "aqua",320+ "aquamarine",321+ "azure",322+ "beige",323+ "bisque",324+ "black",325+ "blanchedalmond",326+ "blue",327+ "blueviolet",328+ "brown",329+ "burlywood",330+ "cadetblue",331+ "chartreuse",332+ "chocolate",333+ "coral",334+ "cornflowerblue",335+ "cornsilk",336+ "crimson",337+ "cyan",338+ "darkblue",339+ "darkcyan",340+ "darkgoldenrod",341+ "darkgray",342+ "darkgreen",343+ "darkgrey",344+ "darkkhaki",345+ "darkmagenta",346+ "darkolivegreen",347+ "darkorange",348+ "darkorchid",349+ "darkred",350+ "darksalmon",351+ "darkseagreen",352+ "darkslateblue",353+ "darkslategray",354+ "darkslategrey",355+ "darkturquoise",356+ "darkviolet",357+ "deeppink",358+ "deepskyblue",359+ "dimgray",360+ "dimgrey",361+ "dodgerblue",362+ "firebrick",363+ "floralwhite",364+ "forestgreen",365+ "fuchsia",366+ "gainsboro",367+ "ghostwhite",368+ "gold",369+ "goldenrod",370+ "gray",371+ "green",372+ "greenyellow",373+ "grey",374+ "honeydew",375+ "hotpink",376+ "indianred",377+ "indigo",378+ "ivory",379+ "khaki",380+ "lavender",381+ "lavenderblush",382+ "lawngreen",383+ "lemonchiffon",384+ "lightblue",385+ "lightcoral",386+ "lightcyan",387+ "lightgoldenrodyellow",388+ "lightgray",389+ "lightgreen",390+ "lightgrey",391+ "lightpink",392+ "lightsalmon",393+ "lightseagreen",394+ "lightskyblue",395+ "lightslategray",396+ "lightslategrey",397+ "lightsteelblue",398+ "lightyellow",399+ "lime",400+ "limegreen",401+ "linen",402+ "magenta",403+ "maroon",404+ "mediumaquamarine",405+ "mediumblue",406+ "mediumorchid",407+ "mediumpurple",408+ "mediumseagreen",409+ "mediumslateblue",410+ "mediumspringgreen",411+ "mediumturquoise",412+ "mediumvioletred",413+ "midnightblue",414+ "mintcream",415+ "mistyrose",416+ "moccasin",417+ "navajowhite",418+ "navy",419+ "oldlace",420+ "olive",421+ "olivedrab",422+ "orange",423+ "orangered",424+ "orchid",425+ "palegoldenrod",426+ "palegreen",427+ "paleturquoise",428+ "palevioletred",429+ "papayawhip",430+ "peachpuff",431+ "peru",432+ "pink",433+ "plum",434+ "powderblue",435+ "purple",436+ "rebeccapurple",437+ "red",438+ "rosybrown",439+ "royalblue",440+ "saddlebrown",441+ "salmon",442+ "sandybrown",443+ "seagreen",444+ "seashell",445+ "sienna",446+ "silver",447+ "skyblue",448+ "slateblue",449+ "slategray",450+ "slategrey",451+ "snow",452+ "springgreen",453+ "steelblue",454+ "tan",455+ "teal",456+ "thistle",457+ "tomato",458+ "turquoise",459+ "violet",460+ "wheat",461+ "white",462+ "whitesmoke",463+ "yellow",464+ "yellowgreen",328465 ];329466 330467 @@ -343,7 +480,10 @@ mod tests {
343480 344481 fn accepts_a_plain_theme() {345482 let theme = css("--pico-primary: #7b3fe4;\n--pico-border-radius: 0.5rem;").unwrap();346- assert_eq!(theme, "--pico-primary: #7b3fe4;\n--pico-border-radius: 0.5rem;\n");483+ assert_eq!(484+ theme,485+ "--pico-primary: #7b3fe4;\n--pico-border-radius: 0.5rem;\n"486+ );347487 }348488 349489 @@ -1,21 +0,0 @@
1-[package]2-name = "devsite-web"3-version.workspace = true4-edition.workspace = true5-license.workspace = true6-7-[lib]8-crate-type = ["cdylib", "rlib"]9-10-[dependencies]11-devsite-client.workspace = true12-devsite-proto.workspace = true13-iroh = { version = "1", default-features = false, features = ["tls-ring"] }14-wasm-bindgen = "0.2"15-wasm-bindgen-futures = "0.4"16-console_error_panic_hook = "0.1"17-tracing.workspace = true18-tracing-subscriber-wasm = "0.1"19-tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] }20-getrandom = { version = "0.3", features = ["wasm_js"] }21-data-encoding.workspace = true@@ -1,87 +0,0 @@
1-//! The browser's Iroh endpoint, exposed to JavaScript.2-//!3-//! This is a thin wasm-bindgen seam over `devsite-client`. All the protocol logic lives in4-//! that crate so it can be tested natively; nothing security-relevant is decided here.5-6-use devsite_client::ViewerEndpoint;7-use devsite_proto::capability::SignedCapability;8-use wasm_bindgen::prelude::*;9-10-/// Install panic and log hooks. Safe to call more than once.11-12-pub fn start() {13- console_error_panic_hook::set_once();14- tracing_subscriber::fmt()15- .with_writer(tracing_subscriber_wasm::MakeConsoleWriter::default())16- .without_time()17- .with_ansi(false)18- .init();19-}20-21-22-pub struct BrowserEndpoint {23- inner: ViewerEndpoint,24-}25-26-27-impl BrowserEndpoint {28- /// Create an ephemeral endpoint for this tab session and wait until it is relay-ready.29- ///30- /// The key never leaves the browser and is discarded when the page unloads, so a31- /// capability bound to it cannot outlive the session it was issued for.32- 33- pub async fn create() -> Result<BrowserEndpoint, JsError> {34- let inner = ViewerEndpoint::create()35- .await36- .map_err(|err| JsError::new(&format!("{err:#}")))?;37- Ok(BrowserEndpoint { inner })38- }39-40- /// This endpoint's public key, to be sent to the control plane so the capability it41- /// issues is bound to this browser and no other.42- 43- pub fn endpoint_id(&self) -> String {44- self.inner.endpoint_id().to_string()45- }46-47- /// Fetch a page from a daemon and return it as text for a sandboxed iframe.48- ///49- /// The daemon is named by its endpoint id and nothing else. Where it can be50- /// reached is iroh's problem: the daemon publishes its own address, and this51- /// endpoint resolves it over HTTPS. The control plane is not asked, and does52- /// not know.53- ///54- /// `capability_b64` comes straight from the control plane and is passed through55- /// opaquely — the browser is a courier for it, not a party to what it says.56- 57- pub async fn fetch_page(58- &self,59- daemon_endpoint_id: String,60- capability_b64: String,61- path: String,62- ) -> Result<String, JsError> {63- let daemon: iroh::EndpointId = daemon_endpoint_id64- .trim()65- .parse()66- .map_err(|_| JsError::new("that does not look like an endpoint id"))?;67- let raw = data_encoding::BASE64URL_NOPAD68- .decode(capability_b64.trim().as_bytes())69- .map_err(|_| JsError::new("capability is not valid base64url"))?;70- let capability = SignedCapability::from_bytes(&raw)71- .map_err(|_| JsError::new("capability is malformed"))?;72-73- let page = self74- .inner75- .fetch(daemon, capability, &path)76- .await77- .map_err(|err| JsError::new(&format!("{err:#}")))?;78-79- if page.status != 200 {80- return Err(JsError::new(&format!(81- "local service returned HTTP {}",82- page.status83- )));84- }85- Ok(page.text())86- }87-}Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.