clone

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

Refactor application architecture and update UI flows

Dami committed ff99393
commit
ff99393fc01128d57d34afb7ecce54047537f507
parent
c22dbd989823d353e2f66b8c3550ee1e0787b589
author
Dami <dami.etoile@gmail.com>
42 files changed, +3318 -2048
D.cargo/config.toml4-
MCargo.lock301+---------
MCargo.toml4+-
MDockerfile19-
MREADME.md267++++----
Mcrates/devsite-cli/Cargo.toml4+-
Mcrates/devsite-cli/src/client.rs1+
Mcrates/devsite-cli/src/main.rs304++++++++--
Mcrates/devsite-client/Cargo.toml3+-
Mcrates/devsite-client/src/lib.rs104+--
Mcrates/devsite-daemon/Cargo.toml4+-
Mcrates/devsite-daemon/src/config.rs101+--
Mcrates/devsite-daemon/src/lib.rs159+++--
Dcrates/devsite-daemon/src/upstream.rs162-----
Mcrates/devsite-daemon/src/verify.rs68+-
Mcrates/devsite-daemon/tests/authz.rs162+++---
Dcrates/devsite-fixture/Cargo.toml9-
Dcrates/devsite-fixture/src/main.rs61--
Mcrates/devsite-proto/src/capability.rs34+-
Mcrates/devsite-proto/src/id.rs6+-
Mcrates/devsite-proto/src/lib.rs10+-
Mcrates/devsite-proto/src/wire.rs57+-
Mcrates/devsite-server/Cargo.toml2+-
Mcrates/devsite-server/src/api.rs946+++++++++++++++++++++++++++---
Mcrates/devsite-server/src/auth.rs49+-
Mcrates/devsite-server/src/db.rs948++++++++++++++++++++++++++++--
Mcrates/devsite-server/src/issuer.rs25+-
Mcrates/devsite-server/src/main.rs90+--
Mcrates/devsite-server/src/policy.rs2+-
Mcrates/devsite-server/src/theme.rs196+++++-
Dcrates/devsite-web/Cargo.toml21-
Dcrates/devsite-web/src/lib.rs87---
Mdeploy/README.md11+-
Ddocs/milestones.md217-------
Mdocs/profile-template.md19+-
Dfixtures/page-a.html38-
Dfixtures/page-b.html38-
Mfly.toml7+-
Dscripts/build-wasm.sh93---
Mweb/app.js585++++++++++++++-----
Mweb/index.html15+-
Mweb/site.css133+++-
D .cargo/config.toml +0 -4
M Cargo.lock +24 -277
@@ -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"
M Cargo.toml +1 -3
M Dockerfile +0 -19
M README.md +136 -131
@@ -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.
M crates/devsite-cli/Cargo.toml +2 -2
M crates/devsite-cli/src/client.rs +1 -0
M crates/devsite-cli/src/main.rs +241 -63
@@ -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 #[derive(Parser)]16-#[command(name = "devsite", about = "Share public work and reach your local services")]20+#[command(21+ name = "devsite",22+ version,23+ about = "Share public work and reach your local services"24+)]1725 struct Cli {1826 /// Control plane base URL.19- #[arg(long, env = "DEVSITE_SERVER", default_value = "https://dev.site", global = true)]27+ #[arg(28+ long,29+ env = "DEVSITE_SERVER",30+ default_value = "https://dev.site",31+ global = true32+ )]2033 server: String,2134 2235 #[command(subcommand)]
@@ -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- #[arg(long)]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 #[arg(long)]45- name: String,46- #[arg(long, conflicts_with_all = ["private", "share"])]47- public: bool,48- #[arg(long, conflicts_with_all = ["public", "share"])]49- private: bool,50- /// Share with a specific user, e.g. --share @bob. Repeatable.58+ name: Option<String>,59+ /// Invite a specific user to accept the share, e.g. --share @bob. Repeatable.5160 #[arg(long, value_name = "@handle")]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 #[arg(long, value_name = "NAME")]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+ #[arg(long, default_value = "127.0.0.1:0")]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+#[derive(Deserialize)]156+struct PublicConfig {157+ api_version: u32,158+ minimum_cli_version: String,159+}160+139161 #[derive(Deserialize)]140162 struct HandleResponse {141163 handle: String,
@@ -146,6 +168,20 @@ struct CreateResourceResponse {
146168 resource_id: String,147169 }148170 171+#[derive(Deserialize)]172+struct CapabilityResponse {173+ capability: String,174+ daemon_endpoint_id: String,175+}176+177+#[derive(Deserialize)]178+struct RedeemTicketResponse {179+ session_token: String,180+ resource_id: String,181+ name: String,182+ expires_at: u64,183+}184+149185 #[derive(Deserialize)]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+}
M crates/devsite-client/Cargo.toml +1 -2
M crates/devsite-client/src/lib.rs +36 -68
@@ -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 #[derive(Debug, thiserror::Error)]34-pub enum FetchError {31+pub enum ConnectError {3532 /// The daemon reached a verdict and refused.3633 #[error("the daemon denied this request")]3734 Denied,
@@ -43,43 +40,34 @@ pub enum FetchError {
4340 Transport(#[from] 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-#[derive(Debug, Clone)]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 }
M crates/devsite-daemon/Cargo.toml +1 -3
M crates/devsite-daemon/src/config.rs +24 -77
@@ -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 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]1818 #[serde(rename_all = "lowercase")]
@@ -22,11 +22,12 @@ pub enum Visibility {
2222 Shared,2323 }2424 25-#[derive(Debug, Clone, Serialize, Deserialize)]25+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]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+ #[serde(default, alias = "session_token")]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 #[cfg(not(unix))]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 #[cfg(test)]207172 mod tests {208173 use super::*;209174 210- fn url(s: &str) -> Url {211- Url::parse(s).unwrap()212- }213-214175 #[test]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- #[test]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 }
M crates/devsite-daemon/src/lib.rs +105 -54
@@ -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 }
D crates/devsite-daemon/src/upstream.rs +0 -162
@@ -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-#[cfg(test)]117-mod tests {118- use super::*;119-120- fn origin() -> Url {121- Url::parse("http://127.0.0.1:4101").unwrap()122- }123-124- #[test]125- fn accepts_ordinary_paths() {126- assert!(upstream_url(&origin(), "/").is_ok());127- assert_eq!(128- upstream_url(&origin(), "/chat").unwrap().as_str(),129- "http://127.0.0.1:4101/chat"130- );131- }132-133- #[test]134- fn refuses_to_become_an_open_proxy() {135- // Each of these, if joined naively, retargets the request at another host.136- for hostile in [137- "http://evil.example/",138- "https://evil.example/",139- "//evil.example/",140- "\\\\evil.example\\",141- ] {142- let result = upstream_url(&origin(), hostile);143- assert!(result.is_err(), "{hostile} should have been rejected");144- }145- }146-147- #[test]148- fn refuses_traversal_and_control_characters() {149- for hostile in ["/../etc/passwd", "/a/../../b", "/x\r\nHost: evil", "/x\0"] {150- assert!(151- upstream_url(&origin(), hostile).is_err(),152- "{hostile} should have been rejected"153- );154- }155- }156-157- #[test]158- fn requires_an_absolute_path() {159- assert!(upstream_url(&origin(), "chat").is_err());160- assert!(upstream_url(&origin(), "").is_err());161- }162-}
M crates/devsite-daemon/src/verify.rs +36 -32
@@ -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 #[derive(Debug)]1716 pub struct Authorized {1817 pub claims: CapabilityClaims,19- pub origin: Url,18+ pub target: SocketAddr,2019 }2120 2221 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -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 #[cfg(test)]
@@ -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 #[test]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
M crates/devsite-daemon/tests/authz.rs +81 -81
@@ -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 #[test]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 }
D crates/devsite-fixture/Cargo.toml +0 -9
D crates/devsite-fixture/src/main.rs +0 -61
@@ -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-#[tokio::main]13-async fn main() -> Result<()> {14- let mut args = std::env::args().skip(1);15- let port: u16 = args16- .next()17- .context("usage: devsite-fixture <port> <file.html>")?18- .parse()19- .context("port must be a number")?;20- let path = PathBuf::from(21- args.next()22- .context("usage: devsite-fixture <port> <file.html>")?,23- );24-25- let body = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;26- let listener = TcpListener::bind(("127.0.0.1", port)).await?;27- println!(28- "devsite-fixture: serving {} on http://127.0.0.1:{port}",29- path.display()30- );31-32- loop {33- let (stream, _) = listener.accept().await?;34- let body = body.clone();35- tokio::spawn(async move {36- if let Err(err) = serve(stream, &body).await {37- eprintln!("devsite-fixture: connection error: {err:#}");38- }39- });40- }41-}42-43-async fn serve(mut stream: TcpStream, body: &[u8]) -> Result<()> {44- // Read just enough to consume the request line and headers; we serve the same file45- // regardless of path, so the request contents don't matter.46- let mut buf = [0u8; 4096];47- let _ = stream.read(&mut buf).await?;48-49- let header = format!(50- "HTTP/1.1 200 OK\r\n\51- Content-Type: text/html; charset=utf-8\r\n\52- Content-Length: {}\r\n\53- Cache-Control: no-store\r\n\54- Connection: close\r\n\r\n",55- body.len()56- );57- stream.write_all(header.as_bytes()).await?;58- stream.write_all(body).await?;59- stream.flush().await?;60- Ok(())61-}
M crates/devsite-proto/src/capability.rs +17 -17
@@ -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 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]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 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -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 #[error("capability was issued for a different daemon")]7676 WrongAudience,77- #[error("capability is bound to a different browser key")]78- WrongBrowserKey,77+ #[error("capability is bound to a different client key")]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 #[test]
@@ -198,14 +198,14 @@ mod tests {
198198 }199199 200200 #[test]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
M crates/devsite-proto/src/id.rs +5 -1
M crates/devsite-proto/src/lib.rs +5 -5
M crates/devsite-proto/src/wire.rs +15 -42
@@ -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 #[derive(Debug, Clone, Serialize, Deserialize)]1515 pub enum Request {16- Http(HttpRequest),16+ Connect(ConnectRequest),1717 }1818 1919 #[derive(Debug, Clone, Serialize, Deserialize)]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-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]31-pub enum Method {32- Get,33- Head,34-}35-36-impl Method {37- pub fn as_str(&self) -> &'static str {38- match self {39- Method::Get => "GET",40- Method::Head => "HEAD",41- }42- }4324 }4425 4526 #[derive(Debug, Clone, Serialize, Deserialize)]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 #[derive(Debug, thiserror::Error)]
@@ -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 #[test]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 #[test]
M crates/devsite-server/Cargo.toml +1 -1
M crates/devsite-server/src/api.rs +862 -84
@@ -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+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]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+#[derive(Default)]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 #[derive(Serialize)]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+#[derive(Deserialize)]254+pub struct SetSharesRequest {255+ #[serde(default)]256+ pub share_with: Vec<String>,257+}258+259+#[derive(Deserialize)]260+pub struct ProfileSettingsRequest {261+ pub private_only: bool,262+}263+264+#[derive(Deserialize)]265+pub struct CreateMachineCredentialRequest {266+ pub name: String,267+}268+157269 #[derive(Serialize)]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+#[derive(Serialize)]307+pub struct ConnectionTicketResponse {308+ pub ticket: String,309+ pub expires_at: u64,310+}311+194312 #[derive(Deserialize)]195-pub struct CapabilityRequest {313+pub struct RedeemTicketRequest {314+ pub ticket: String,315+ pub client_endpoint_id: String,316+}317+318+#[derive(Serialize)]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 #[derive(Serialize)]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+ #[test]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 #[test]6941398 fn parses_a_valid_endpoint_id() {6951399 let hex = "89ef8f68b58d5dbfa8501010a0f2ea3afaf80952f04e6767fadbf6d16658e7a0";
@@ -699,11 +1403,36 @@ mod tests {
6991403 7001404 #[test]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+ #[test]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 #[test]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 #[test]
@@ -722,12 +1454,58 @@ mod tests {
7221454 assert!(validate_folder(Some("two\nlines")).is_err());7231455 }7241456 1457+ #[test]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+ #[test]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+ #[test]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 #[test]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 }
M crates/devsite-server/src/auth.rs +46 -3
@@ -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 #[cfg(test)]
@@ -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 #[test]
@@ -214,8 +243,22 @@ mod tests {
214243 215244 #[test]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 }
M crates/devsite-server/src/db.rs +896 -52
@@ -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+#[derive(Debug, Clone, PartialEq, Eq)]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+#[derive(Debug, Clone, Copy, PartialEq, Eq)]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+#[derive(Debug, Clone, PartialEq, Eq)]204+pub struct ShareRecipient {205+ pub handle: String,206+ pub status: ShareStatus,207+}208+209+#[derive(Debug, Clone)]210+pub struct IncomingShare {211+ pub resource: Resource,212+ pub owner_handle: String,213+ pub status: ShareStatus,214+}215+216+#[derive(Debug, Clone, PartialEq, Eq)]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 #[derive(Debug, Clone, Copy, PartialEq, Eq)]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 #[allow(clippy::too_many_arguments)]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+ #[test]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 #[test]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 #[test]
@@ -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+ #[test]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+ #[test]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 #[test]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+ #[test]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 #[test]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 #[test]
@@ -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 #[test]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+ #[test]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+ #[test]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 #[test]8061650 fn handles_are_matched_case_insensitively() {8071651 let (db, alice, _) = seeded();
M crates/devsite-server/src/issuer.rs +17 -8
@@ -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 #[cfg(not(unix))]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();
M crates/devsite-server/src/main.rs +41 -49
@@ -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-#[cfg(test)]195-mod tests {196- use super::*;197-198- #[test]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+#[cfg(test)]226+mod tests {227+ use super::*;228+229+ #[test]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+}
M crates/devsite-server/src/policy.rs +1 -1
M crates/devsite-server/src/theme.rs +168 -28
@@ -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 #[cfg(test)]
@@ -343,7 +480,10 @@ mod tests {
343480 #[test]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 #[test]
D crates/devsite-web/Cargo.toml +0 -21
D crates/devsite-web/src/lib.rs +0 -87
@@ -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-#[wasm_bindgen(start)]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-#[wasm_bindgen]22-pub struct BrowserEndpoint {23- inner: ViewerEndpoint,24-}25-26-#[wasm_bindgen]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- #[wasm_bindgen]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- #[wasm_bindgen(getter, js_name = endpointId)]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- #[wasm_bindgen(js_name = fetchPage)]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-}
M deploy/README.md +3 -8

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

D docs/milestones.md +0 -217

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

M docs/profile-template.md +7 -12

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

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

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

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

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

M fly.toml +1 -6

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

D scripts/build-wasm.sh +0 -93

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

M web/app.js +439 -146

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

M web/index.html +12 -3

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

M web/site.css +93 -40

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