authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 21:48:47-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
loga7246389ae8115bab036e4edf5f5240d06901251
treeebec2479e86771c559bfbd03ba70cb505e354be5
parent2f9d63330af4cabbe5e6d66c465e4ee9fa529f5a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Move Snowglobe and Files authentication into the Rust dashboard

Preserve account and credential IDs, import existing password hashes and passkeys, and add revocable invitations with optional passkey onboarding. Use native host-bound sessions for Snowglobe and Copyparty while retaining password-protected file shares and Keycloak for other services. Back up and restore native account and connection stores with verified SQLite copies. Include the verified MCP catalog and consent pages plus prompted cross-platform agent installers. Redirect the unsupported nested userscript URL to discord-pluralkit-predict. Assisted-by: gpt-6.1-sol

47 files changed, 3776 insertions(+), 809 deletions(-)

dashboard/.gitignore+1
......@@ -3,3 +3,4 @@ dist/
33.cache/
44data/
55target/
6agent/relay.mjs
dashboard/Cargo.lock+443-8
......@@ -20,6 +20,57 @@ dependencies = [
2020 "libc",
2121]
2222
23[[package]]
24name = "argon2"
25version = "0.5.3"
26source = "registry+https://github.com/rust-lang/crates.io-index"
27checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
28dependencies = [
29 "base64ct",
30 "blake2",
31 "cpufeatures 0.2.17",
32 "password-hash",
33]
34
35[[package]]
36name = "asn1-rs"
37version = "0.6.2"
38source = "registry+https://github.com/rust-lang/crates.io-index"
39checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
40dependencies = [
41 "asn1-rs-derive",
42 "asn1-rs-impl",
43 "displaydoc",
44 "nom",
45 "num-traits",
46 "rusticata-macros",
47 "thiserror 1.0.69",
48 "time",
49]
50
51[[package]]
52name = "asn1-rs-derive"
53version = "0.5.1"
54source = "registry+https://github.com/rust-lang/crates.io-index"
55checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
56dependencies = [
57 "proc-macro2",
58 "quote",
59 "syn 2.0.119",
60 "synstructure 0.13.2",
61]
62
63[[package]]
64name = "asn1-rs-impl"
65version = "0.2.0"
66source = "registry+https://github.com/rust-lang/crates.io-index"
67checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
68dependencies = [
69 "proc-macro2",
70 "quote",
71 "syn 2.0.119",
72]
73
2374[[package]]
2475name = "async-trait"
2576version = "0.1.92"
......@@ -99,6 +150,12 @@ dependencies = [
99150 "tracing",
100151]
101152
153[[package]]
154name = "base64"
155version = "0.21.7"
156source = "registry+https://github.com/rust-lang/crates.io-index"
157checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
158
102159[[package]]
103160name = "base64"
104161version = "0.22.1"
......@@ -111,12 +168,38 @@ version = "0.23.1"
111168source = "registry+https://github.com/rust-lang/crates.io-index"
112169checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
113170
171[[package]]
172name = "base64ct"
173version = "1.8.3"
174source = "registry+https://github.com/rust-lang/crates.io-index"
175checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
176
177[[package]]
178name = "base64urlsafedata"
179version = "0.5.5"
180source = "registry+https://github.com/rust-lang/crates.io-index"
181checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
182dependencies = [
183 "base64 0.21.7",
184 "pastey 0.1.1",
185 "serde",
186]
187
114188[[package]]
115189name = "bitflags"
116190version = "2.13.2"
117191source = "registry+https://github.com/rust-lang/crates.io-index"
118192checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
119193
194[[package]]
195name = "blake2"
196version = "0.10.6"
197source = "registry+https://github.com/rust-lang/crates.io-index"
198checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
199dependencies = [
200 "digest",
201]
202
120203[[package]]
121204name = "block-buffer"
122205version = "0.10.4"
......@@ -225,6 +308,12 @@ dependencies = [
225308 "libc",
226309]
227310
311[[package]]
312name = "crunchy"
313version = "0.2.4"
314source = "registry+https://github.com/rust-lang/crates.io-index"
315checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
316
228317[[package]]
229318name = "crypto-common"
230319version = "0.1.7"
......@@ -264,6 +353,26 @@ version = "2.11.1"
264353source = "registry+https://github.com/rust-lang/crates.io-index"
265354checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
266355
356[[package]]
357name = "der-parser"
358version = "9.0.0"
359source = "registry+https://github.com/rust-lang/crates.io-index"
360checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
361dependencies = [
362 "asn1-rs",
363 "displaydoc",
364 "nom",
365 "num-bigint",
366 "num-traits",
367 "rusticata-macros",
368]
369
370[[package]]
371name = "deranged"
372version = "0.5.8"
373source = "registry+https://github.com/rust-lang/crates.io-index"
374checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
375
267376[[package]]
268377name = "derive_more"
269378version = "2.1.1"
......@@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
293402dependencies = [
294403 "block-buffer",
295404 "crypto-common",
405 "subtle",
296406]
297407
298408[[package]]
......@@ -393,6 +503,21 @@ version = "0.1.5"
393503source = "registry+https://github.com/rust-lang/crates.io-index"
394504checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
395505
506[[package]]
507name = "foreign-types"
508version = "0.3.2"
509source = "registry+https://github.com/rust-lang/crates.io-index"
510checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
511dependencies = [
512 "foreign-types-shared",
513]
514
515[[package]]
516name = "foreign-types-shared"
517version = "0.1.1"
518source = "registry+https://github.com/rust-lang/crates.io-index"
519checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
520
396521[[package]]
397522name = "form_urlencoded"
398523version = "1.2.2"
......@@ -552,6 +677,17 @@ dependencies = [
552677 "regex-syntax",
553678]
554679
680[[package]]
681name = "half"
682version = "2.7.1"
683source = "registry+https://github.com/rust-lang/crates.io-index"
684checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
685dependencies = [
686 "cfg-if",
687 "crunchy",
688 "zerocopy",
689]
690
555691[[package]]
556692name = "hashbrown"
557693version = "0.15.5"
......@@ -576,10 +712,17 @@ dependencies = [
576712 "hashbrown 0.15.5",
577713]
578714
715[[package]]
716name = "hex"
717version = "0.4.3"
718source = "registry+https://github.com/rust-lang/crates.io-index"
719checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
720
579721[[package]]
580722name = "home-dashboard"
581723version = "0.1.0"
582724dependencies = [
725 "argon2",
583726 "axum",
584727 "base64 0.22.1",
585728 "bytes",
......@@ -592,6 +735,8 @@ dependencies = [
592735 "rmcp",
593736 "rusqlite",
594737 "scraper",
738 "serde",
739 "serde_cbor_2",
595740 "serde_json",
596741 "sha1",
597742 "sha2",
......@@ -602,6 +747,7 @@ dependencies = [
602747 "url",
603748 "uuid",
604749 "walkdir",
750 "webauthn-rs",
605751]
606752
607753[[package]]
......@@ -889,6 +1035,12 @@ dependencies = [
8891035 "wasm-bindgen",
8901036]
8911037
1038[[package]]
1039name = "lazy_static"
1040version = "1.5.1"
1041source = "registry+https://github.com/rust-lang/crates.io-index"
1042checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1043
8921044[[package]]
8931045name = "libc"
8941046version = "0.2.189"
......@@ -972,6 +1124,12 @@ dependencies = [
9721124 "unicase",
9731125]
9741126
1127[[package]]
1128name = "minimal-lexical"
1129version = "0.2.1"
1130source = "registry+https://github.com/rust-lang/crates.io-index"
1131checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1132
9751133[[package]]
9761134name = "mio"
9771135version = "1.2.3"
......@@ -1012,6 +1170,41 @@ version = "1.0.6"
10121170source = "registry+https://github.com/rust-lang/crates.io-index"
10131171checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
10141172
1173[[package]]
1174name = "nom"
1175version = "7.1.3"
1176source = "registry+https://github.com/rust-lang/crates.io-index"
1177checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1178dependencies = [
1179 "memchr",
1180 "minimal-lexical",
1181]
1182
1183[[package]]
1184name = "num-bigint"
1185version = "0.4.8"
1186source = "registry+https://github.com/rust-lang/crates.io-index"
1187checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
1188dependencies = [
1189 "num-integer",
1190 "num-traits",
1191]
1192
1193[[package]]
1194name = "num-conv"
1195version = "0.2.2"
1196source = "registry+https://github.com/rust-lang/crates.io-index"
1197checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1198
1199[[package]]
1200name = "num-integer"
1201version = "0.1.47"
1202source = "registry+https://github.com/rust-lang/crates.io-index"
1203checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
1204dependencies = [
1205 "num-traits",
1206]
1207
10151208[[package]]
10161209name = "num-traits"
10171210version = "0.2.19"
......@@ -1021,12 +1214,58 @@ dependencies = [
10211214 "autocfg",
10221215]
10231216
1217[[package]]
1218name = "oid-registry"
1219version = "0.7.1"
1220source = "registry+https://github.com/rust-lang/crates.io-index"
1221checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
1222dependencies = [
1223 "asn1-rs",
1224]
1225
10241226[[package]]
10251227name = "once_cell"
10261228version = "1.21.4"
10271229source = "registry+https://github.com/rust-lang/crates.io-index"
10281230checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
10291231
1232[[package]]
1233name = "openssl"
1234version = "0.10.81"
1235source = "registry+https://github.com/rust-lang/crates.io-index"
1236checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
1237dependencies = [
1238 "bitflags",
1239 "cfg-if",
1240 "foreign-types",
1241 "libc",
1242 "openssl-macros",
1243 "openssl-sys",
1244]
1245
1246[[package]]
1247name = "openssl-macros"
1248version = "0.1.1"
1249source = "registry+https://github.com/rust-lang/crates.io-index"
1250checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1251dependencies = [
1252 "proc-macro2",
1253 "quote",
1254 "syn 2.0.119",
1255]
1256
1257[[package]]
1258name = "openssl-sys"
1259version = "0.9.117"
1260source = "registry+https://github.com/rust-lang/crates.io-index"
1261checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
1262dependencies = [
1263 "cc",
1264 "libc",
1265 "pkg-config",
1266 "vcpkg",
1267]
1268
10301269[[package]]
10311270name = "parking_lot"
10321271version = "0.12.5"
......@@ -1050,6 +1289,23 @@ dependencies = [
10501289 "windows-link",
10511290]
10521291
1292[[package]]
1293name = "password-hash"
1294version = "0.5.0"
1295source = "registry+https://github.com/rust-lang/crates.io-index"
1296checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
1297dependencies = [
1298 "base64ct",
1299 "rand_core 0.6.4",
1300 "subtle",
1301]
1302
1303[[package]]
1304name = "pastey"
1305version = "0.1.1"
1306source = "registry+https://github.com/rust-lang/crates.io-index"
1307checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
1308
10531309[[package]]
10541310name = "pastey"
10551311version = "0.2.3"
......@@ -1136,6 +1392,12 @@ dependencies = [
11361392 "zerovec",
11371393]
11381394
1395[[package]]
1396name = "powerfmt"
1397version = "0.2.0"
1398source = "registry+https://github.com/rust-lang/crates.io-index"
1399checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1400
11391401[[package]]
11401402name = "ppv-lite86"
11411403version = "0.2.21"
......@@ -1174,7 +1436,7 @@ dependencies = [
11741436 "rustc-hash",
11751437 "rustls",
11761438 "socket2",
1177 "thiserror",
1439 "thiserror 2.0.21",
11781440 "tokio",
11791441 "tracing",
11801442 "web-time",
......@@ -1196,7 +1458,7 @@ dependencies = [
11961458 "rustls",
11971459 "rustls-pki-types",
11981460 "slab",
1199 "thiserror",
1461 "thiserror 2.0.21",
12001462 "tinyvec",
12011463 "tracing",
12021464 "web-time",
......@@ -1268,6 +1530,12 @@ dependencies = [
12681530 "rand_core 0.9.5",
12691531]
12701532
1533[[package]]
1534name = "rand_core"
1535version = "0.6.4"
1536source = "registry+https://github.com/rust-lang/crates.io-index"
1537checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1538
12711539[[package]]
12721540name = "rand_core"
12731541version = "0.9.5"
......@@ -1419,14 +1687,14 @@ dependencies = [
14191687 "http-body",
14201688 "http-body-util",
14211689 "indexmap",
1422 "pastey",
1690 "pastey 0.2.3",
14231691 "pin-project-lite",
14241692 "rand 0.10.3",
14251693 "schemars",
14261694 "serde",
14271695 "serde_json",
14281696 "sse-stream",
1429 "thiserror",
1697 "thiserror 2.0.21",
14301698 "tokio",
14311699 "tokio-stream",
14321700 "tokio-util",
......@@ -1464,6 +1732,15 @@ dependencies = [
14641732 "semver",
14651733]
14661734
1735[[package]]
1736name = "rusticata-macros"
1737version = "4.1.0"
1738source = "registry+https://github.com/rust-lang/crates.io-index"
1739checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
1740dependencies = [
1741 "nom",
1742]
1743
14671744[[package]]
14681745name = "rustls"
14691746version = "0.23.45"
......@@ -1601,6 +1878,16 @@ dependencies = [
16011878 "serde_derive",
16021879]
16031880
1881[[package]]
1882name = "serde_cbor_2"
1883version = "0.13.0"
1884source = "registry+https://github.com/rust-lang/crates.io-index"
1885checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
1886dependencies = [
1887 "half",
1888 "serde",
1889]
1890
16041891[[package]]
16051892name = "serde_core"
16061893version = "1.0.229"
......@@ -1835,6 +2122,17 @@ dependencies = [
18352122 "futures-core",
18362123]
18372124
2125[[package]]
2126name = "synstructure"
2127version = "0.13.2"
2128source = "registry+https://github.com/rust-lang/crates.io-index"
2129checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
2130dependencies = [
2131 "proc-macro2",
2132 "quote",
2133 "syn 2.0.119",
2134]
2135
18382136[[package]]
18392137name = "synstructure"
18402138version = "0.14.0"
......@@ -1855,13 +2153,33 @@ dependencies = [
18552153 "new_debug_unreachable",
18562154]
18572155
2156[[package]]
2157name = "thiserror"
2158version = "1.0.69"
2159source = "registry+https://github.com/rust-lang/crates.io-index"
2160checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2161dependencies = [
2162 "thiserror-impl 1.0.69",
2163]
2164
18582165[[package]]
18592166name = "thiserror"
18602167version = "2.0.21"
18612168source = "registry+https://github.com/rust-lang/crates.io-index"
18622169checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
18632170dependencies = [
1864 "thiserror-impl",
2171 "thiserror-impl 2.0.21",
2172]
2173
2174[[package]]
2175name = "thiserror-impl"
2176version = "1.0.69"
2177source = "registry+https://github.com/rust-lang/crates.io-index"
2178checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
2179dependencies = [
2180 "proc-macro2",
2181 "quote",
2182 "syn 2.0.119",
18652183]
18662184
18672185[[package]]
......@@ -1875,6 +2193,36 @@ dependencies = [
18752193 "syn 3.0.6",
18762194]
18772195
2196[[package]]
2197name = "time"
2198version = "0.3.55"
2199source = "registry+https://github.com/rust-lang/crates.io-index"
2200checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
2201dependencies = [
2202 "deranged",
2203 "num-conv",
2204 "powerfmt",
2205 "serde_core",
2206 "time-core",
2207 "time-macros",
2208]
2209
2210[[package]]
2211name = "time-core"
2212version = "0.1.9"
2213source = "registry+https://github.com/rust-lang/crates.io-index"
2214checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
2215
2216[[package]]
2217name = "time-macros"
2218version = "0.2.32"
2219source = "registry+https://github.com/rust-lang/crates.io-index"
2220checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
2221dependencies = [
2222 "num-conv",
2223 "time-core",
2224]
2225
18782226[[package]]
18792227name = "tinystr"
18802228version = "0.8.4"
......@@ -2073,7 +2421,7 @@ dependencies = [
20732421 "log",
20742422 "rand 0.9.5",
20752423 "sha1",
2076 "thiserror",
2424 "thiserror 2.0.21",
20772425]
20782426
20792427[[package]]
......@@ -2110,6 +2458,7 @@ dependencies = [
21102458 "idna",
21112459 "percent-encoding",
21122460 "serde",
2461 "serde_derive",
21132462]
21142463
21152464[[package]]
......@@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
21262475dependencies = [
21272476 "getrandom 0.4.3",
21282477 "js-sys",
2478 "serde_core",
21292479 "wasm-bindgen",
21302480]
21312481
......@@ -2263,6 +2613,74 @@ dependencies = [
22632613 "string_cache_codegen",
22642614]
22652615
2616[[package]]
2617name = "webauthn-attestation-ca"
2618version = "0.5.5"
2619source = "registry+https://github.com/rust-lang/crates.io-index"
2620checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e"
2621dependencies = [
2622 "base64urlsafedata",
2623 "openssl",
2624 "openssl-sys",
2625 "serde",
2626 "tracing",
2627 "uuid",
2628]
2629
2630[[package]]
2631name = "webauthn-rs"
2632version = "0.5.5"
2633source = "registry+https://github.com/rust-lang/crates.io-index"
2634checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422"
2635dependencies = [
2636 "base64urlsafedata",
2637 "serde",
2638 "tracing",
2639 "url",
2640 "uuid",
2641 "webauthn-rs-core",
2642]
2643
2644[[package]]
2645name = "webauthn-rs-core"
2646version = "0.5.5"
2647source = "registry+https://github.com/rust-lang/crates.io-index"
2648checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a"
2649dependencies = [
2650 "base64 0.21.7",
2651 "base64urlsafedata",
2652 "der-parser",
2653 "hex",
2654 "nom",
2655 "openssl",
2656 "openssl-sys",
2657 "rand 0.9.5",
2658 "rand_chacha",
2659 "serde",
2660 "serde_cbor_2",
2661 "serde_json",
2662 "thiserror 1.0.69",
2663 "tracing",
2664 "url",
2665 "uuid",
2666 "webauthn-attestation-ca",
2667 "webauthn-rs-proto",
2668 "x509-parser",
2669]
2670
2671[[package]]
2672name = "webauthn-rs-proto"
2673version = "0.5.5"
2674source = "registry+https://github.com/rust-lang/crates.io-index"
2675checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e"
2676dependencies = [
2677 "base64 0.21.7",
2678 "base64urlsafedata",
2679 "serde",
2680 "serde_json",
2681 "url",
2682]
2683
22662684[[package]]
22672685name = "webpki-roots"
22682686version = "1.0.9"
......@@ -2434,6 +2852,23 @@ version = "0.6.4"
24342852source = "registry+https://github.com/rust-lang/crates.io-index"
24352853checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
24362854
2855[[package]]
2856name = "x509-parser"
2857version = "0.16.0"
2858source = "registry+https://github.com/rust-lang/crates.io-index"
2859checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
2860dependencies = [
2861 "asn1-rs",
2862 "data-encoding",
2863 "der-parser",
2864 "lazy_static",
2865 "nom",
2866 "oid-registry",
2867 "rusticata-macros",
2868 "thiserror 1.0.69",
2869 "time",
2870]
2871
24372872[[package]]
24382873name = "yoke"
24392874version = "0.8.3"
......@@ -2454,7 +2889,7 @@ dependencies = [
24542889 "proc-macro2",
24552890 "quote",
24562891 "syn 3.0.6",
2457 "synstructure",
2892 "synstructure 0.14.0",
24582893]
24592894
24602895[[package]]
......@@ -2495,7 +2930,7 @@ dependencies = [
24952930 "proc-macro2",
24962931 "quote",
24972932 "syn 3.0.6",
2498 "synstructure",
2933 "synstructure 0.14.0",
24992934]
25002935
25012936[[package]]
dashboard/Cargo.toml+4
......@@ -4,6 +4,7 @@ version = "0.1.0"
44edition = "2024"
55
66[dependencies]
7argon2 = "0.5"
78axum = { version = "0.8.9", features = ["multipart", "ws"] }
89base64 = "0.22"
910bytes = "1"
......@@ -15,6 +16,8 @@ regex = "1"
1516reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
1617rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
1718rusqlite = { version = "0.37", features = ["bundled"] }
19serde = { version = "1", features = ["derive"] }
20serde_cbor_2 = "0.13"
1821scraper = { version = "0.27", default-features = false }
1922serde_json = "1"
2023sha1 = "0.10"
......@@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] }
2629url = "2"
2730uuid = { version = "1", features = ["v4"] }
2831walkdir = "2"
32webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
2933
3034[profile.release]
3135lto = "thin"
dashboard/agent/install.ps1 created+41
......@@ -0,0 +1,41 @@
1$ErrorActionPreference = 'Stop'
2$Server = __SERVER__
3
4function Install-Agent {
5 $Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
6 $Principal = New-Object Security.Principal.WindowsPrincipal($Identity)
7 if ($Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
8 throw 'Run this installer from your usual PowerShell window, without administrator privileges.'
9 }
10 $Base = Join-Path $env:LOCALAPPDATA 'AgentRelay'
11 $Stage = Join-Path $Base ('.install.' + [guid]::NewGuid().ToString('N'))
12 New-Item -ItemType Directory -Force $Base | Out-Null
13 & icacls.exe $Base /inheritance:r /grant:r ('*' + $Identity.User.Value + ':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' | Out-Null
14 if ($LASTEXITCODE -ne 0) { throw 'Unable to protect the agent folder. Check its permissions and retry.' }
15 try {
16 New-Item -ItemType Directory -Force $Stage | Out-Null
17 $Node = Join-Path $Base 'node.exe'
18 if (!(Test-Path $Node)) {
19 Write-Host 'Downloading the agent runtime…'
20 $Arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { 'arm64' } else { 'x64' }
21 $Checksums = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 'https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt').Content
22 $Match = [regex]::Match($Checksums, "(?m)^([a-f0-9]{64})\s+(node-(v24\.[0-9]+\.[0-9]+)-win-$Arch\.zip)\s*$")
23 if (!$Match.Success) { throw 'No runtime download is available for this machine.' }
24 $Archive = Join-Path $Stage $Match.Groups[2].Value
25 Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 ("https://nodejs.org/dist/" + $Match.Groups[3].Value + '/' + $Match.Groups[2].Value) -OutFile $Archive
26 if ((Get-FileHash $Archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Match.Groups[1].Value) {
27 throw 'The runtime checksum did not match. Run the installer again.'
28 }
29 Expand-Archive $Archive $Stage
30 Copy-Item (Join-Path $Stage ($Match.Groups[2].Value.Replace('.zip', '') + '\node.exe')) $Node
31 }
32 Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/relay.mjs" -OutFile (Join-Path $Stage 'relay.mjs')
33 Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/setup.mjs" -OutFile (Join-Path $Stage 'setup.mjs')
34 & $Node (Join-Path $Stage 'setup.mjs') install $Server $Base
35 if ($LASTEXITCODE -ne 0) { throw 'Installation stopped. Correct the problem above, then run the installer again.' }
36 } finally {
37 Remove-Item -Recurse -Force $Stage
38 }
39}
40
41Install-Agent
dashboard/agent/install.sh created+53
......@@ -0,0 +1,53 @@
1#!/bin/sh
2set -eu
3umask 077
4server=__SERVER__
5
6install_agent() {
7 [ "$(id -u)" != 0 ] || { echo 'Run this installer as your login user, without sudo.' >&2; return 1; }
8 case $(uname -s) in
9 Linux) os=linux; base=${XDG_DATA_HOME:-"$HOME/.local/share"}/agent-relay
10 command -v systemctl >/dev/null || { echo 'This installer needs a systemd user session.' >&2; return 1; }
11 systemctl --user show-environment >/dev/null || { echo 'Sign in to a systemd user session, then run the installer again.' >&2; return 1; } ;;
12 Darwin) os=darwin; base="$HOME/Library/Application Support/AgentRelay" ;;
13 *) echo "Use $server/agent/install.ps1 from Windows PowerShell." >&2; return 1 ;;
14 esac
15 case $(uname -m) in
16 x86_64|amd64) arch=x64 ;;
17 aarch64|arm64) arch=arm64 ;;
18 *) echo 'This installer supports x64 and arm64 machines.' >&2; return 1 ;;
19 esac
20 command -v curl >/dev/null || { echo 'Install curl, then run this installer again.' >&2; return 1; }
21 mkdir -p "$base"
22 chmod 700 "$base"
23 stage=$(mktemp -d "$base/.install.XXXXXX")
24 trap 'rm -rf "$stage"' EXIT HUP INT TERM
25 if [ ! -x "$base/node" ] && [ -f /etc/NIXOS ]; then
26 echo 'Installing the agent runtime…'
27 nix --extra-experimental-features 'nix-command flakes' build nixpkgs#nodejs_24 --out-link "$base/node-runtime"
28 ln -sf "$base/node-runtime/bin/node" "$base/node"
29 elif [ ! -x "$base/node" ]; then
30 echo 'Downloading the agent runtime…'
31 curl -fsSL https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt -o "$stage/checksums"
32 archive=$(awk -v suffix="-$os-$arch.tar.gz" '$2 ~ /^node-v24\./ && substr($2, length($2)-length(suffix)+1) == suffix {print $2}' "$stage/checksums")
33 [ -n "$archive" ] || { echo 'No runtime download is available for this machine.' >&2; return 1; }
34 version=${archive#node-}; version=${version%%-$os-*}
35 curl -fsSL "https://nodejs.org/dist/$version/$archive" -o "$stage/$archive"
36 expected=$(awk -v file="$archive" '$2 == file {print $1}' "$stage/checksums")
37 if command -v sha256sum >/dev/null; then
38 actual=$(sha256sum "$stage/$archive"); actual=${actual%% *}
39 else
40 actual=$(shasum -a 256 "$stage/$archive"); actual=${actual%% *}
41 fi
42 [ "$actual" = "$expected" ] || { echo 'The runtime checksum did not match. Run the installer again.' >&2; return 1; }
43 tar -xzf "$stage/$archive" -C "$stage"
44 cp "$stage/${archive%.tar.gz}/bin/node" "$base/node"
45 chmod 700 "$base/node"
46 fi
47 curl -fsSL "$server/agent/relay.mjs" -o "$stage/relay.mjs"
48 curl -fsSL "$server/agent/setup.mjs" -o "$stage/setup.mjs"
49 "$base/node" "$stage/setup.mjs" install "$server" "$base" </dev/tty
50}
51
52# The shell must read the whole script before the installer opens the terminal.
53install_agent
dashboard/agent/setup.mjs created+192
......@@ -0,0 +1,192 @@
1import { execFileSync, spawn, spawnSync } from 'node:child_process';
2import { copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3import { homedir, hostname } from 'node:os';
4import { delimiter, dirname, join, resolve } from 'node:path';
5import { createInterface } from 'node:readline/promises';
6import { setTimeout as sleep } from 'node:timers/promises';
7
8const [action = 'status', server, installDir] = process.argv.slice(2);
9const base = installDir ?? dirname(process.argv[1]);
10const windows = process.platform === 'win32';
11const mac = process.platform === 'darwin';
12const data = windows ? join(base, 'config') : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'agent-relay');
13const unit = mac ? join(homedir(), 'Library/LaunchAgents/net.paperclover.agent-relay.plist') :
14 join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'systemd/user/agent-relay.service');
15const task = windows ? 'AgentRelay-' + execFileSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8' }).match(/S-1-5-[\d-]+/)[0] : '';
16const domain = mac ? `gui/${process.getuid()}` : '';
17const ps = (script) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'inherit' });
18const psQuote = (text) => "'" + text.replaceAll("'", "''") + "'";
19const shellQuote = (text) => "'" + text.replaceAll("'", "'\\''") + "'";
20
21async function stop() {
22 if (windows) ps(`$ErrorActionPreference = 'Stop'
23if (Get-ScheduledTask -TaskName ${psQuote(task)} -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName ${psQuote(task)} }
24Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object {
25 $_.ExecutablePath -eq ${psQuote(join(base, 'node.exe'))} -and $_.CommandLine.Contains(${psQuote(join(base, 'relay.mjs'))})
26} | ForEach-Object {
27 & taskkill.exe /PID $_.ProcessId /T /F | Out-Null
28 if ($LASTEXITCODE -ne 0 -and (Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue)) { throw 'Unable to stop the previous agent. Close it and run the installer again.' }
29}`);
30 else if (mac) {
31 if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status !== 0) return;
32 execFileSync('launchctl', ['bootout', `${domain}/net.paperclover.agent-relay`]);
33 for (let attempt = 0; attempt < 40; attempt++) {
34 try { execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }); }
35 catch { return; }
36 await sleep(250);
37 }
38 throw new Error('The previous agent is still stopping. Wait and run the installer again.');
39 }
40 else if (spawnSync('systemctl', ['--user', 'show', '-P', 'LoadState', 'agent-relay.service'], { encoding: 'utf8' }).stdout.trim() === 'loaded') {
41 execFileSync('systemctl', ['--user', 'stop', 'agent-relay.service']);
42 }
43}
44
45async function main() {
46 if (action === 'stop') { await stop(); return; }
47 if (action === 'start') {
48 if (windows) ps(`$ErrorActionPreference = 'Stop'; Start-ScheduledTask -TaskName ${psQuote(task)}`);
49 else if (mac) {
50 if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status === 0) {
51 execFileSync('launchctl', ['kickstart', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' });
52 } else execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' });
53 }
54 else execFileSync('systemctl', ['--user', 'start', 'agent-relay.service'], { stdio: 'inherit' });
55 return;
56 }
57 if (action === 'status') {
58 if (windows) ps(`$ErrorActionPreference = 'Stop'; Get-ScheduledTask -TaskName ${psQuote(task)} | Select-Object TaskName,State`);
59 else if (mac) execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' });
60 else execFileSync('systemctl', ['--user', 'status', '--no-pager', 'agent-relay.service'], { stdio: 'inherit' });
61 return;
62 }
63 if (action === 'uninstall') {
64 await stop();
65 if (windows) ps(`$ErrorActionPreference = 'Stop'; Unregister-ScheduledTask -TaskName ${psQuote(task)} -Confirm:$false`);
66 else {
67 if (!mac) execFileSync('systemctl', ['--user', 'disable', 'agent-relay.service'], { stdio: 'inherit' });
68 await rm(unit, { force: true });
69 if (!mac) execFileSync('systemctl', ['--user', 'daemon-reload']);
70 }
71 console.log(`Startup removed. Pairing and files remain in ${base} and ${data}.`);
72 return;
73 }
74 if (action !== 'install' || !server || !installDir) throw new Error('Use install, status, start, stop, or uninstall.');
75 const origin = new URL(server);
76 if (origin.origin !== server || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) {
77 throw new Error('Use an HTTPS dashboard origin, or localhost for a preview.');
78 }
79 const staged = dirname(process.argv[1]);
80 let previous;
81 try { previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8')); }
82 catch (error) { if (error.code !== 'ENOENT') throw error; }
83 if (previous && previous.server !== server) throw new Error(`This machine is paired to ${previous.server}. Unlink it there before changing dashboards.`);
84 const input = createInterface({ input: process.stdin, output: process.stdout });
85 const closed = new AbortController();
86 input.once('close', () => closed.abort());
87 const ask = (text) => input.question(text, { signal: closed.signal });
88 let name, desktopWrite;
89 const roots = [];
90 try {
91 console.log(`Agent Relay · ${server}\nCodex and Claude Code must already be installed and signed in.`);
92 console.log('Linked clients can read saved Codex and Claude Code chats on this machine.');
93 name = previous ? 'this machine' : (await ask(`Machine name [${hostname()}]: `)).trim() || hostname();
94 if (previous) console.log('The existing machine pairing will be kept.');
95 if (name.length > 100) throw new Error('Enter a machine name up to 100 characters.');
96 console.log('Allowed folders apply to new chats. Existing chats keep their own permissions.');
97 if (previous?.roots?.length) console.log(`Current folders: ${previous.roots.join(', ')}`);
98 console.log('Enter one project folder at a time. Leave blank to finish.');
99 if (previous) console.log('Leave the first answer blank to keep the current folders. Enter - to clear them.');
100 while (true) {
101 const answer = (await ask('Project folder: ')).trim();
102 if (!answer) { if (!roots.length && previous) roots.push(...previous.roots); break; }
103 if (answer === '-' && !roots.length) break;
104 const path = await realpath(resolve(answer === '~' ? homedir() : answer.startsWith('~/') ? join(homedir(), answer.slice(2)) : answer));
105 if (!(await stat(path)).isDirectory()) throw new Error('Choose an existing project folder.');
106 if (!roots.includes(path)) roots.push(path);
107 }
108 if (!windows) {
109 console.log('Experimental Codex desktop control lets linked clients send messages and interrupt chats. App updates may break it.');
110 const answer = (await ask(`Enable desktop control? [${previous?.desktopWrite ? 'Y/n' : 'y/N'}]: `)).trim().toLowerCase();
111 if (answer && !['y', 'yes', 'n', 'no'].includes(answer)) throw new Error('Answer yes or no.');
112 desktopWrite = answer ? ['y', 'yes'].includes(answer) : previous?.desktopWrite ?? false;
113 } else desktopWrite = false;
114 } catch (error) {
115 if (closed.signal.aborted) throw new Error('Keep the terminal open to answer the install prompts, then run the installer again.');
116 throw error;
117 } finally { input.close(); }
118 await mkdir(data, { recursive: true, mode: 0o700 });
119 if (previous) {
120 const response = await fetch(`${server}/pairing`, { headers: { Authorization: `Bearer ${previous.token}` }, signal: AbortSignal.timeout(10_000) });
121 if (!response.ok) throw new Error(`The saved pairing is unavailable (${response.status}). Check the dashboard before reinstalling.`);
122 } else {
123 await new Promise((accept, reject) => {
124 const child = spawn(process.execPath, [join(staged, 'relay.mjs'), 'pair', '--server', server, '--name', name, '--data-dir', data,
125 ...roots.flatMap((root) => ['--allow-root', root]), ...(desktopWrite ? ['--codex-desktop-write'] : [])], { stdio: 'inherit' });
126 child.on('error', reject);
127 child.on('exit', (code) => code === 0 ? accept() : reject(new Error('Pairing stopped. Run the installer again for a new code.')));
128 });
129 previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8'));
130 }
131 const config = { ...previous, roots, desktopWrite };
132 const binaries = {};
133 for (const cli of ['codex', 'claude']) {
134 try {
135 const found = windows ? execFileSync('where.exe', [cli], { encoding: 'utf8' }).trim().split(/\r?\n/)[0] :
136 execFileSync('/bin/sh', ['-c', 'command -v "$1"', 'sh', cli], { encoding: 'utf8' }).trim();
137 if (found) binaries[cli] = found;
138 } catch { console.log(`${cli} was not found. Install it and rerun this installer to enable its chats.`); }
139 }
140 await stop();
141 await writeFile(join(data, 'agent.json.pending'), JSON.stringify(config) + '\n', { mode: 0o600 });
142 await rename(join(data, 'agent.json.pending'), join(data, 'agent.json'));
143 for (const file of ['relay.mjs', 'setup.mjs']) await copyFile(join(staged, file), join(base, file));
144 const args = [join(base, 'relay.mjs'), 'run', '--data-dir', data,
145 ...Object.entries(binaries).flatMap(([cli, path]) => [`--${cli}-bin`, path])];
146 const environment = Object.fromEntries(['PATH', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
147 environment.PATH = [base, environment.PATH].filter(Boolean).join(delimiter);
148 if (windows) {
149 const runner = join(base, 'run.ps1');
150 await writeFile(runner, "$ErrorActionPreference = 'Stop'\n" + Object.entries(environment).map(([key, value]) => `$env:${key} = ${psQuote(value)}`).join('\n') +
151 `\n& ${psQuote(process.execPath)} ${args.map(psQuote).join(' ')} *>> ${psQuote(join(base, 'agent.log'))}\nexit $LASTEXITCODE\n`);
152 ps(`$ErrorActionPreference = 'Stop'
153$user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
154$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${psQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${runner}"`)}
155$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
156$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
157$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
158Register-ScheduledTask -TaskName ${psQuote(task)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
159Start-ScheduledTask -TaskName ${psQuote(task)}
160if ((Get-ScheduledTask -TaskName ${psQuote(task)}).State -eq 'Disabled') { throw 'Enable the Agent Relay task and run the installer again.' }`);
161 await writeFile(join(base, 'agent-relay.cmd'), `@echo off\r\n"${process.execPath}" "${join(base, 'setup.mjs')}" %*\r\n`);
162 } else {
163 await mkdir(dirname(unit), { recursive: true });
164 if (mac) {
165 const xml = (text) => text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
166 await writeFile(unit, `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
167<key>Label</key><string>net.paperclover.agent-relay</string>
168<key>ProgramArguments</key><array>${[process.execPath, ...args].map((arg) => `<string>${xml(arg)}</string>`).join('')}</array>
169<key>EnvironmentVariables</key><dict>${Object.entries(environment).map(([key, value]) => `<key>${key}</key><string>${xml(value)}</string>`).join('')}</dict>
170<key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer>
171<key>StandardOutPath</key><string>${xml(join(base, 'agent.log'))}</string>
172<key>StandardErrorPath</key><string>${xml(join(base, 'agent.log'))}</string>
173</dict></plist>\n`);
174 execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' });
175 execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' });
176 } else {
177 const quote = (text) => '"' + text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('%', '%%') + '"';
178 await writeFile(unit, `[Unit]\nDescription=Agent Relay\n\n[Service]\nExecStart=${[process.execPath, ...args].map((arg) => quote(arg).replaceAll('$', '$$')).join(' ')}\n` +
179 Object.entries(environment).map(([key, value]) => `Environment=${quote(`${key}=${value}`)}`).join('\n') +
180 '\nRestart=on-failure\nRestartSec=30\nUMask=0077\n\n[Install]\nWantedBy=default.target\n');
181 execFileSync('systemctl', ['--user', 'daemon-reload']);
182 execFileSync('systemctl', ['--user', 'enable', '--now', 'agent-relay.service'], { stdio: 'inherit' });
183 execFileSync('systemctl', ['--user', 'is-active', '--quiet', 'agent-relay.service']);
184 }
185 await writeFile(join(base, 'agent-relay'), `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(join(base, 'setup.mjs'))} "$@"\n`, { mode: 0o700 });
186 }
187 console.log(`Installed. Agent Relay starts at login.\nOpen ${server}/mcp/settings/agents and check that ${name} is online.`);
188 const manage = join(base, windows ? 'agent-relay.cmd' : 'agent-relay');
189 console.log(`Check startup: ${windows ? '& ' + psQuote(manage) : shellQuote(manage)} status\nUse start, stop, or uninstall in place of status.`);
190}
191
192main().catch((error) => { console.error(error.message); process.exitCode = 1; });
dashboard/agent/source.json created+4
......@@ -0,0 +1,4 @@
1{
2 "source": "../../../agent-relay",
3 "entry": "src/agent.ts"
4}
dashboard/package.json+1
......@@ -15,6 +15,7 @@
1515 "@solidjs/router": "^1.0.0",
1616 "@types/node": "^26.6.2",
1717 "concurrently": "^10.0.5",
18 "esbuild": "0.28.2",
1819 "lucide-solid": "^1.48.0",
1920 "solid-js": "^1.9.15",
2021 "typescript": "^7.0.2",
dashboard/pnpm-lock.yaml+3-1
......@@ -21,6 +21,9 @@ importers:
2121 concurrently:
2222 specifier: ^10.0.5
2323 version: 10.0.5
24 esbuild:
25 specifier: 0.28.2
26 version: 0.28.2
2427 lucide-solid:
2528 specifier: ^1.48.0
2629 version: 1.48.0(solid-js@1.9.15)
......@@ -1379,7 +1382,6 @@ snapshots:
13791382 '@esbuild/win32-arm64': 0.28.2
13801383 '@esbuild/win32-ia32': 0.28.2
13811384 '@esbuild/win32-x64': 0.28.2
1382 optional: true
13831385
13841386 escalade@3.2.0: {}
13851387
dashboard/src/auth.rs created+1114
......@@ -0,0 +1,1114 @@
1use crate::*;
2use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
3use base64::{
4 Engine,
5 engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD},
6};
7use rusqlite::{Connection, OptionalExtension, params as sql};
8use std::os::unix::fs::PermissionsExt;
9use webauthn_rs::prelude::*;
10
11const COOKIE: &str = "__Host-snow-session";
12const FLOW_COOKIE: &str = "__Host-snow-flow";
13const SESSION_TTL: i64 = 30 * 86400;
14const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm"];
15
16pub struct Store {
17 pub db: Mutex<Connection>,
18 pub origin: url::Url,
19 file: url::Url,
20 webauthn: Webauthn,
21 passwords: Semaphore,
22}
23
24pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
25 headers
26 .get("cookie")?
27 .to_str()
28 .ok()?
29 .split(';')
30 .find_map(|part| {
31 let (key, value) = part.trim().split_once('=')?;
32 (key == name).then(|| value.to_owned())
33 })
34}
35fn set_cookie(name: &str, value: &str, ttl: i64) -> String {
36 format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}")
37}
38fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> {
39 let value: Option<String> = db.query_row(statement, [key], |r| r.get(0)).optional()?;
40 Ok(value
41 .map(|s| serde_json::from_str(&s))
42 .transpose()?
43 .unwrap_or(Value::Null))
44}
45fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result<Value> {
46 let value: Option<String> = db
47 .query_row(
48 "SELECT data FROM pending WHERE hash=? AND kind=? AND expires>?",
49 sql![mcp::hash(token), kind, now() as i64],
50 |r| r.get(0),
51 )
52 .optional()?;
53 if consume && value.is_some() {
54 db.execute("DELETE FROM pending WHERE hash=?", [mcp::hash(token)])?;
55 }
56 Ok(value
57 .map(|s| serde_json::from_str(&s))
58 .transpose()?
59 .unwrap_or(Value::Null))
60}
61fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result<String> {
62 db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?;
63 let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?;
64 if count >= 4096 {
65 return Err(Error::new(
66 429,
67 "Too many sign-in requests. Try again in a few minutes.",
68 ));
69 }
70 let token = mcp::secret();
71 db.execute(
72 "INSERT INTO pending VALUES (?,?,?,?)",
73 sql![
74 mcp::hash(&token),
75 kind,
76 value.to_string(),
77 now() as i64 + ttl
78 ],
79 )?;
80 Ok(token)
81}
82pub fn user(db: &Connection, id: &str) -> Result<Value> {
83 let mut profile = row(db, "SELECT profile FROM users WHERE id=?", id)?;
84 if profile.is_null() {
85 return Err(Error::new(
86 404,
87 "This account no longer exists. Sign in again.",
88 ));
89 }
90 profile["id"] = json!(id);
91 let mut statement = db.prepare("SELECT roles.id, roles.name FROM roles JOIN memberships ON roles.id=memberships.role_id WHERE user_id=? ORDER BY roles.name")?;
92 profile["groups"] = json!(
93 statement
94 .query_map([id], |r| Ok(
95 json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?})
96 ))?
97 .collect::<std::result::Result<Vec<_>, _>>()?
98 );
99 Ok(profile)
100}
101pub fn credentials(db: &Connection, id: &str) -> Result<Value> {
102 let mut statement = db.prepare(
103 "SELECT id, kind, label, created FROM credentials WHERE user_id=? ORDER BY created",
104 )?;
105 Ok(json!(statement.query_map([id], |r| Ok(json!({"id":r.get::<_,String>(0)?,"type":r.get::<_,String>(1)?,"userLabel":r.get::<_,Option<String>>(2)?,"createdDate":r.get::<_,i64>(3)?})))?.collect::<std::result::Result<Vec<_>,_>>()?))
106}
107pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> {
108 for key in [
109 "id",
110 "groups",
111 "sessions",
112 "credentials",
113 "picture",
114 "console",
115 ] {
116 profile.as_object_mut().unwrap().remove(key);
117 }
118 db.execute(
119 "UPDATE users SET profile=? WHERE id=?",
120 sql![profile.to_string(), id],
121 )
122 .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?;
123 Ok(())
124}
125pub fn password_hash(password: &str) -> Result<String> {
126 let salt = SaltString::encode_b64(&rand::random::<[u8; 16]>())
127 .map_err(|_| Error::new(500, "Couldn't prepare password storage."))?;
128 Ok(Argon2::default()
129 .hash_password(password.as_bytes(), &salt)
130 .map_err(|_| Error::new(500, "Couldn't store the password."))?
131 .to_string())
132}
133pub fn set_password(db: &Connection, id: &str, hash: &str) -> Result<()> {
134 db.execute(
135 "DELETE FROM credentials WHERE user_id=? AND kind='password'",
136 [id],
137 )?;
138 db.execute(
139 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
140 sql![
141 uuid::Uuid::new_v4().to_string(),
142 id,
143 "password",
144 Option::<String>::None,
145 (now() * 1000.0) as i64,
146 json!({"phc":hash}).to_string()
147 ],
148 )?;
149 Ok(())
150}
151
152impl Store {
153 pub fn new(data: &std::path::Path, origin: &str, file: &str, rp: &str) -> Result<Self> {
154 let origin = url::Url::parse(origin)?;
155 let file = url::Url::parse(file)?;
156 if origin.scheme() != "https"
157 || file.scheme() != "https"
158 || origin.path() != "/"
159 || file.path() != "/"
160 || origin.origin() == file.origin()
161 {
162 return Err(Error::new(
163 500,
164 "Set separate HTTPS origins for Snowglobe and Files.",
165 ));
166 }
167 let rp_origin = url::Url::parse(&format!("https://{rp}"))?;
168 let webauthn = WebauthnBuilder::new(rp, &rp_origin)?
169 .append_allowed_origin(&origin)
170 .rp_name("snow globe")
171 .build()?;
172 std::fs::create_dir_all(data)?;
173 let path = data.canonicalize()?.join("accounts.sqlite");
174 let db = Connection::open_with_flags(
175 &path,
176 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
177 | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
178 | rusqlite::OpenFlags::SQLITE_OPEN_NOFOLLOW,
179 )?;
180 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
181 db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
182 CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, profile TEXT NOT NULL, username TEXT GENERATED ALWAYS AS (json_extract(profile,'$.username')) STORED UNIQUE);
183 CREATE UNIQUE INDEX IF NOT EXISTS verified_email ON users(lower(json_extract(profile,'$.email'))) WHERE json_extract(profile,'$.emailVerified')=1 AND json_extract(profile,'$.email') IS NOT NULL;
184 CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE);
185 CREATE TABLE IF NOT EXISTS memberships (user_id TEXT REFERENCES users(id) ON DELETE CASCADE, role_id TEXT REFERENCES roles(id), PRIMARY KEY(user_id,role_id));
186 CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,kind TEXT NOT NULL,label TEXT,created INTEGER NOT NULL,data TEXT NOT NULL);
187 CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,client TEXT NOT NULL CHECK(client IN ('dashboard','file')),expires INTEGER NOT NULL,ip TEXT NOT NULL,created INTEGER NOT NULL,last_used INTEGER NOT NULL,auth_time INTEGER NOT NULL);
188 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);
189 CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY);
190 CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?;
191 Ok(Self {
192 db: Mutex::new(db),
193 origin,
194 file,
195 webauthn,
196 passwords: Semaphore::new(2),
197 })
198 }
199 pub fn ready(&self) -> bool {
200 self.db
201 .lock()
202 .unwrap()
203 .query_row("SELECT EXISTS(SELECT 1 FROM users)", [], |r| r.get(0))
204 .unwrap_or(false)
205 }
206 pub fn import(&self, export: Value) -> Result<Value> {
207 if self
208 .webauthn
209 .get_allowed_origins()
210 .first()
211 .and_then(|u| u.host_str())
212 != export["rpId"].as_str()
213 {
214 return Err(Error::new(
215 400,
216 "The export's passkey domain does not match this server.",
217 ));
218 }
219 let mut db = self.db.lock().unwrap();
220 let digest = mcp::hash(&export.to_string());
221 if db.query_row(
222 "SELECT EXISTS(SELECT 1 FROM migration WHERE digest=?)",
223 [&digest],
224 |r| r.get::<_, bool>(0),
225 )? {
226 return Ok(
227 json!({"accounts":array(&export["users"]).len(),"credentials":array(&export["users"]).iter().map(|u|array(&u["credentials"]).len()).sum::<usize>()}),
228 );
229 }
230 if db.query_row("SELECT count(*) FROM users", [], |r| r.get::<_, i64>(0))? != 0 {
231 return Err(Error::new(
232 409,
233 "Accounts already exist. Import into an empty store.",
234 ));
235 }
236 let transaction = db.transaction()?;
237 for role in array(&export["roles"]) {
238 if GROUPS.contains(&string(&role["name"])) {
239 transaction.execute(
240 "INSERT INTO roles VALUES (?,?)",
241 sql![string(&role["id"]), string(&role["name"])],
242 )?;
243 }
244 }
245 let mut count = 0;
246 for profile in array(&export["users"]) {
247 let id = string(&profile["id"]);
248 uuid::Uuid::parse_str(id)?;
249 string(&profile["username"])
250 .parse::<axum::http::HeaderValue>()
251 .map_err(|_| Error::new(400, "The source has an invalid username."))?;
252 let mut value = profile.clone();
253 value["requiredActions"] = json!(
254 array(&profile["requiredActions"])
255 .iter()
256 .filter(|v| **v == "UPDATE_PASSWORD" || **v == "UPDATE_PROFILE")
257 .collect::<Vec<_>>()
258 );
259 for key in ["id", "roles", "credentials"] {
260 value.as_object_mut().unwrap().remove(key);
261 }
262 transaction.execute(
263 "INSERT INTO users(id,profile) VALUES (?,?)",
264 sql![id, value.to_string()],
265 )?;
266 for role in array(&profile["roles"]) {
267 transaction.execute(
268 "INSERT INTO memberships SELECT ?,id FROM roles WHERE id=?",
269 sql![id, string(role)],
270 )?;
271 }
272 for credential in array(&profile["credentials"]) {
273 let kind = string(&credential["type"]);
274 let source = &credential["credentialData"];
275 let data = match kind {
276 "password" => {
277 if source["algorithm"] != "argon2"
278 || source["additionalParameters"]["type"][0] != "id"
279 {
280 return Err(Error::new(
281 500,
282 "The source uses an unsupported password format.",
283 ));
284 }
285 let parameters = &source["additionalParameters"];
286 let salt = STANDARD_NO_PAD
287 .encode(STANDARD.decode(string(&credential["secretData"]["salt"]))?);
288 let hash = STANDARD_NO_PAD
289 .encode(STANDARD.decode(string(&credential["secretData"]["value"]))?);
290 let phc = format!(
291 "$argon2id$v=19$m={},t={},p={}${}${}",
292 string(&parameters["memory"][0]),
293 source["hashIterations"],
294 string(&parameters["parallelism"][0]),
295 salt,
296 hash
297 );
298 PasswordHash::new(&phc).map_err(|_| {
299 Error::new(500, "The source password hash couldn't be imported.")
300 })?;
301 json!({"phc":phc})
302 }
303 "webauthn-passwordless" => {
304 let key: serde_cbor_2::Value = serde_cbor_2::from_slice(
305 &URL_SAFE_NO_PAD.decode(string(&source["credentialPublicKey"]))?,
306 )?;
307 let public_key = COSEKey::try_from(&key)?;
308 let cred = Credential {
309 cred_id: STANDARD.decode(string(&source["credentialId"]))?.into(),
310 cred: public_key,
311 counter: source["counter"].as_u64().unwrap_or(0).try_into()?,
312 transports: serde_json::from_value(source["transports"].clone())
313 .unwrap_or(None),
314 user_verified: true,
315 backup_eligible: false,
316 backup_state: false,
317 registration_policy: serde_json::from_value(json!("required"))?,
318 extensions: Default::default(),
319 attestation: Default::default(),
320 attestation_format: AttestationFormat::None,
321 };
322 // Keycloak omits backup flags; learn them only from the first verified assertion.
323 json!({"passkey":Passkey::from(cred),"handle":URL_SAFE_NO_PAD.encode(id.as_bytes()),"backupUnknown":true})
324 }
325 _ => {
326 return Err(Error::new(
327 500,
328 "The source has a credential type this import doesn't support.",
329 ));
330 }
331 };
332 transaction.execute(
333 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
334 sql![
335 string(&credential["id"]),
336 id,
337 kind,
338 credential["userLabel"].as_str(),
339 credential["createdDate"].as_i64().unwrap_or(0),
340 data.to_string()
341 ],
342 )?;
343 count += 1;
344 }
345 }
346 transaction.execute("INSERT INTO migration VALUES (?)", [digest])?;
347 transaction.commit()?;
348 Ok(json!({"accounts":array(&export["users"]).len(),"credentials":count}))
349 }
350 pub fn session(&self, headers: &HeaderMap, client: &str) -> Result<Value> {
351 let Some(token) = cookie(headers, COOKIE) else {
352 return Ok(Value::Null);
353 };
354 let db = self.db.lock().unwrap();
355 let id: Option<String> = db
356 .query_row(
357 "SELECT user_id FROM sessions WHERE hash=? AND client=? AND expires>?",
358 sql![mcp::hash(&token), client, now() as i64],
359 |r| r.get(0),
360 )
361 .optional()?;
362 let Some(id) = id else {
363 return Ok(Value::Null);
364 };
365 let user = user(&db, &id)?;
366 if user["enabled"] != true {
367 return Ok(Value::Null);
368 }
369 db.execute(
370 "UPDATE sessions SET last_used=? WHERE hash=? AND last_used<?",
371 sql![
372 (now() * 1000.0) as i64,
373 mcp::hash(&token),
374 (now() * 1000.0) as i64 - 60000
375 ],
376 )?;
377 Ok(user)
378 }
379 fn create_session(
380 &self,
381 id: &str,
382 client: &str,
383 headers: &HeaderMap,
384 password: Option<&Value>,
385 ) -> Result<String> {
386 let token = mcp::secret();
387 let db = self.db.lock().unwrap();
388 if user(&db, id)?["enabled"] != true {
389 return Err(Error::new(403, "This account is disabled."));
390 }
391 if let Some(expected) = password {
392 if row(
393 &db,
394 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
395 id,
396 )? != *expected
397 {
398 return Err(Error::new(401, "Your password changed. Sign in again."));
399 }
400 }
401 db.execute("DELETE FROM sessions WHERE expires<=?", [now() as i64])?;
402 let ip = headers
403 .get("X-Studio-Client-IP")
404 .and_then(|v| v.to_str().ok())
405 .unwrap_or("unknown");
406 db.execute(
407 "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)",
408 sql![
409 mcp::hash(&token),
410 id,
411 client,
412 now() as i64 + SESSION_TTL,
413 ip,
414 (now() * 1000.0) as i64,
415 (now() * 1000.0) as i64,
416 now() as i64
417 ],
418 )?;
419 Ok(set_cookie(COOKIE, &token, SESSION_TTL))
420 }
421 fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> {
422 let ip = headers
423 .get("X-Studio-Client-IP")
424 .and_then(|v| v.to_str().ok())
425 .unwrap_or("unknown");
426 let db = self.db.lock().unwrap();
427 db.execute("DELETE FROM attempts WHERE expires<=?", [now() as i64])?;
428 let address = mcp::hash(ip);
429 db.execute(
430 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
431 sql![address, now() as i64 + 300],
432 )?;
433 let total: i64 =
434 db.query_row("SELECT count FROM attempts WHERE key=?", [address], |r| {
435 r.get(0)
436 })?;
437 if total > 100 {
438 return Err(Error::new(
439 429,
440 "Too many attempts. Try again in five minutes.",
441 ));
442 }
443 let key = mcp::hash(&format!("{ip}:{name}"));
444 db.execute(
445 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
446 sql![key, now() as i64 + 300],
447 )?;
448 let count: i64 = db.query_row("SELECT count FROM attempts WHERE key=?", [key], |r| {
449 r.get(0)
450 })?;
451 if count > 20 {
452 return Err(Error::new(
453 429,
454 "Too many attempts. Try again in five minutes.",
455 ));
456 }
457 Ok(())
458 }
459 fn csrf(&self, headers: &HeaderMap, body: &Value) -> Result<()> {
460 if headers.get("origin").and_then(|v| v.to_str().ok())
461 != Some(self.origin.origin().ascii_serialization().as_str())
462 {
463 return Err(Error::new(403, "Open sign-in on Snowglobe and try again."));
464 }
465 let cookie = cookie(headers, FLOW_COOKIE).unwrap_or_default();
466 if cookie.is_empty()
467 || !bool::from(cookie.as_bytes().ct_eq(string(&body["csrf"]).as_bytes()))
468 || pending(&self.db.lock().unwrap(), &cookie, "csrf", false)?.is_null()
469 {
470 return Err(Error::new(
471 403,
472 "Sign-in expired. Reload the page and try again.",
473 ));
474 }
475 Ok(())
476 }
477 fn next(&self, id: &str, flow: &str, path: &str) -> Result<String> {
478 if flow.is_empty() {
479 if !path.starts_with('/')
480 || path.starts_with("//")
481 || path.contains('\\')
482 || path.chars().any(char::is_control)
483 {
484 return Ok("/".into());
485 }
486 return Ok(path.to_owned());
487 }
488 let db = self.db.lock().unwrap();
489 if !array(&user(&db, id)?["requiredActions"]).is_empty() {
490 return Ok("/account".into());
491 }
492 let value = pending(&db, flow, "file", false)?;
493 if value.is_null() {
494 return Err(Error::new(
495 400,
496 "File sign-in expired. Open Files and try again.",
497 ));
498 }
499 let code = issue(&db, "handoff", json!({"user":id,"flow":flow}), 60)?;
500 Ok(format!(
501 "{}auth/file/callback?code={}",
502 self.file,
503 encoded(&code)
504 ))
505 }
506 pub fn sessions(db: &Connection, id: &str) -> Result<Value> {
507 let mut statement = db.prepare("SELECT hash,ip,created,last_used,client FROM sessions WHERE user_id=? AND expires>? ORDER BY last_used DESC")?;
508 Ok(json!(statement.query_map(sql![id,now() as i64],|r|Ok(json!({"id":r.get::<_,String>(0)?,"ipAddress":r.get::<_,String>(1)?,"start":r.get::<_,i64>(2)?,"lastAccess":r.get::<_,i64>(3)?,"clients":{"snow":r.get::<_,String>(4)?}})))?.collect::<std::result::Result<Vec<_>,_>>()?))
509 }
510 pub async fn hash_password(&self, password: &str) -> Result<String> {
511 let _slot = self
512 .passwords
513 .try_acquire()
514 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
515 let password = password.to_owned();
516 tokio::task::spawn_blocking(move || password_hash(&password)).await?
517 }
518 pub fn recent(&self, headers: &HeaderMap) -> Result<()> {
519 let token = cookie(headers, COOKIE).unwrap_or_default();
520 let valid: bool = self.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM sessions WHERE hash=? AND client='dashboard' AND expires>? AND auth_time>?)",sql![mcp::hash(&token),now() as i64,now() as i64-900],|r|r.get(0))?;
521 if !valid {
522 return Err(Error::new(
523 403,
524 "Sign out and sign in again before changing sign-in methods.",
525 ));
526 }
527 Ok(())
528 }
529 pub fn setup_link(&self, id: &str) -> Result<String> {
530 let db = self.db.lock().unwrap();
531 let profile = user(&db, id)?;
532 if profile["enabled"] != true {
533 return Err(Error::new(
534 400,
535 "Enable this account before creating a setup link.",
536 ));
537 }
538 db.execute(
539 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
540 [id],
541 )?;
542 let token = issue(&db, "setup", json!({"user":id}), 86400)?;
543 Ok(format!("{}sign-in?setup={}", self.origin, token))
544 }
545}
546
547pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
548 let auth = &app.auth;
549 let path = request.uri().path().to_owned();
550 let method = request.method().clone();
551 let query: HashMap<String, String> =
552 url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes())
553 .into_owned()
554 .collect();
555 let headers = request.headers().clone();
556 if path == "/auth/file/check" && method == Method::GET {
557 let user = auth.session(&headers, "file")?;
558 if user.is_null() || !array(&user["requiredActions"]).is_empty() {
559 return Ok(StatusCode::UNAUTHORIZED.into_response());
560 }
561 let groups = array(&user["groups"])
562 .iter()
563 .map(|g| string(&g["name"]))
564 .collect::<Vec<_>>()
565 .join(",");
566 return Ok((
567 StatusCode::NO_CONTENT,
568 [
569 (
570 "X-Auth-Request-Preferred-Username",
571 string(&user["username"]).to_owned(),
572 ),
573 ("X-Auth-Request-Groups", groups),
574 ],
575 )
576 .into_response());
577 }
578 if path == "/auth/file/sign-in" && method == Method::GET {
579 let target = query
580 .get("rd")
581 .map(String::as_str)
582 .unwrap_or(auth.file.as_str());
583 let destination = auth.file.join(target)?;
584 if destination.origin() != auth.file.origin()
585 || !destination.username().is_empty()
586 || destination.password().is_some()
587 {
588 return Err(Error::new(400, "Open Files to sign in."));
589 }
590 let flow = issue(
591 &auth.db.lock().unwrap(),
592 "file",
593 json!({"next":destination}),
594 300,
595 )?;
596 return Ok((
597 StatusCode::FOUND,
598 [
599 (
600 "location",
601 format!("{}auth/continue?flow={flow}", auth.origin),
602 ),
603 ("set-cookie", set_cookie(FLOW_COOKIE, &flow, 300)),
604 ],
605 )
606 .into_response());
607 }
608 if path == "/auth/continue" && method == Method::GET {
609 let flow = query.get("flow").cloned().unwrap_or_default();
610 let user = auth.session(&headers, "dashboard")?;
611 let next = if user.is_null() {
612 format!("/sign-in?flow={}", encoded(&flow))
613 } else {
614 auth.next(string(&user["id"]), &flow, "/")?
615 };
616 return Ok((StatusCode::FOUND, [("location", next)]).into_response());
617 }
618 if path == "/auth/file/callback" && method == Method::GET {
619 let token = query.get("code").cloned().unwrap_or_default();
620 let (id, next) = {
621 let mut db = auth.db.lock().unwrap();
622 let transaction = db.transaction()?;
623 let code = pending(&transaction, &token, "handoff", false)?;
624 let flow = cookie(&headers, FLOW_COOKIE).unwrap_or_default();
625 if code.is_null()
626 || flow.is_empty()
627 || !bool::from(flow.as_bytes().ct_eq(string(&code["flow"]).as_bytes()))
628 {
629 return Err(Error::new(
630 403,
631 "File sign-in expired. Open Files and try again.",
632 ));
633 }
634 let target = pending(&transaction, &flow, "file", true)?;
635 if target.is_null() {
636 return Err(Error::new(
637 403,
638 "File sign-in expired. Open Files and try again.",
639 ));
640 }
641 pending(&transaction, &token, "handoff", true)?;
642 let user = user(&transaction, string(&code["user"]))?;
643 if user["enabled"] != true {
644 return Err(Error::new(403, "This account is disabled. Contact Clover."));
645 }
646 let result = (
647 string(&code["user"]).to_owned(),
648 string(&target["next"]).to_owned(),
649 );
650 transaction.commit()?;
651 result
652 };
653 let session = auth.create_session(&id, "file", &headers, None)?;
654 return Ok((
655 StatusCode::FOUND,
656 [("location", next), ("set-cookie", session)],
657 )
658 .into_response());
659 }
660 if path == "/auth/status" && method == Method::GET {
661 let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?;
662 let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?});
663 if let Some(setup) = query.get("setup") {
664 let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?;
665 if entry.is_null() {
666 return Err(Error::new(
667 410,
668 "This link expired. Ask Clover for a new one.",
669 ));
670 }
671 value["setup"] =
672 user(&auth.db.lock().unwrap(), string(&entry["user"]))?["username"].clone();
673 }
674 return Ok((
675 [
676 ("set-cookie", set_cookie(FLOW_COOKIE, &csrf, 900)),
677 ("cache-control", "no-store".into()),
678 ],
679 axum::Json(value),
680 )
681 .into_response());
682 }
683 if path == "/auth/sign-out" || path == "/auth/file/sign-out" {
684 if method == Method::GET && path == "/auth/file/sign-out" {
685 return Ok(axum::response::Html("<!doctype html><html><meta name=viewport content='width=device-width'><title>Sign out of Files</title><body><form method=post action='/auth/file/sign-out'><button>sign out of Files</button></form></body></html>").into_response());
686 }
687 if method != Method::POST {
688 return Err(Error::new(405, "Use the sign-out button."));
689 }
690 let expected = if path.contains("/file/") {
691 &auth.file
692 } else {
693 &auth.origin
694 };
695 if headers.get("origin").and_then(|v| v.to_str().ok())
696 != Some(expected.origin().ascii_serialization().as_str())
697 {
698 return Err(Error::new(403, "Open your account to sign out."));
699 }
700 if let Some(token) = cookie(&headers, COOKIE) {
701 auth.db
702 .lock()
703 .unwrap()
704 .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?;
705 }
706 if path.contains("/file/") {
707 return Ok((
708 StatusCode::SEE_OTHER,
709 [
710 ("set-cookie", set_cookie(COOKIE, "", 0)),
711 ("location", "/".into()),
712 ],
713 )
714 .into_response());
715 }
716 return Ok((
717 [("set-cookie", set_cookie(COOKIE, "", 0))],
718 axum::Json(json!({"next":"/sign-in"})),
719 )
720 .into_response());
721 }
722 if method != Method::POST {
723 return Err(Error::new(404, "No sign-in action here."));
724 }
725 let body: Value =
726 serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 128 * 1024).await?)
727 .map_err(|_| Error::new(400, "Reload the form and try again."))?;
728 auth.csrf(&headers, &body)?;
729 if path == "/auth/password" || path == "/auth/passkey/start" {
730 let name = string(&body["username"]).trim().to_lowercase();
731 if name.len() > 254 || name.is_empty() {
732 return Err(Error::new(400, "Enter your username."));
733 }
734 auth.limit(&headers, &name)?;
735 let id: Option<String> = auth.db.lock().unwrap().query_row("SELECT id FROM users WHERE username=? OR (lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1) ORDER BY username=? DESC LIMIT 1",sql![name,name,name],|r|r.get(0)).optional()?;
736 let user = id
737 .as_ref()
738 .map(|id| user(&auth.db.lock().unwrap(), id))
739 .transpose()?
740 .unwrap_or(Value::Null);
741 if path == "/auth/password" {
742 let password = string(&body["password"]).to_owned();
743 if password.len() > 1024 {
744 return Err(Error::new(400, "That password is too long."));
745 }
746 let data = row(
747 &auth.db.lock().unwrap(),
748 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
749 id.as_deref().unwrap_or(""),
750 )?;
751 let phc = string(&data["phc"]).to_owned();
752 let _slot = auth
753 .passwords
754 .try_acquire()
755 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
756 let verified = tokio::task::spawn_blocking(move || {
757 if phc.is_empty() {
758 let _ = password_hash(&password);
759 return false;
760 }
761 PasswordHash::new(&phc).is_ok_and(|hash| {
762 Argon2::default()
763 .verify_password(password.as_bytes(), &hash)
764 .is_ok()
765 })
766 })
767 .await?;
768 if !verified || user["enabled"] != true {
769 return Err(Error::new(
770 401,
771 "That username or password doesn't match. Try again.",
772 ));
773 }
774 let id = id.unwrap();
775 let session = auth.create_session(&id, "dashboard", &headers, Some(&data))?;
776 let next = if !array(&user["requiredActions"]).is_empty() {
777 "/account".into()
778 } else {
779 auth.next(&id, string(&body["flow"]), string(&body["next"]))?
780 };
781 return Ok(
782 ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(),
783 );
784 }
785 if user["enabled"] != true {
786 return Err(Error::new(
787 401,
788 "No passkey is available for that username. Try your password.",
789 ));
790 }
791 let id = id.unwrap();
792 let keys = passkeys(&auth.db.lock().unwrap(), &id)?;
793 if keys.is_empty() {
794 return Err(Error::new(
795 401,
796 "No passkey is available for that username. Try your password.",
797 ));
798 }
799 let (options, state) = auth.webauthn.start_passkey_authentication(&keys)?;
800 let token = issue(
801 &auth.db.lock().unwrap(),
802 "authentication",
803 json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"]}),
804 300,
805 )?;
806 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
807 }
808 if path == "/auth/passkey/finish" {
809 let value = pending(
810 &auth.db.lock().unwrap(),
811 string(&body["token"]),
812 "authentication",
813 true,
814 )?;
815 if value.is_null() || value["csrf"] != body["csrf"] {
816 return Err(Error::new(403, "Passkey sign-in expired. Try again."));
817 }
818 let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone())
819 .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?;
820 let mut state = value["state"].clone();
821 let mut allowed: Vec<Credential> =
822 serde_json::from_value(state["ast"]["credentials"].clone())?;
823 {
824 let db = auth.db.lock().unwrap();
825 let mut statement = db.prepare(
826 "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
827 )?;
828 for stored in
829 statement.query_map([string(&value["user"])], |r| r.get::<_, String>(0))?
830 {
831 let stored: Value = serde_json::from_str(&stored?)?;
832 let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?;
833 if stored["backupUnknown"] == true
834 && passkey.cred_id().as_slice() == credential.get_credential_id()
835 {
836 let flags = credential
837 .response
838 .authenticator_data
839 .as_slice()
840 .get(32)
841 .copied()
842 .ok_or_else(|| Error::new(400, "The passkey response was incomplete."))?;
843 for key in &mut allowed {
844 if key.cred_id == *passkey.cred_id() {
845 key.backup_eligible = flags & 8 != 0;
846 key.backup_state = flags & 16 != 0;
847 }
848 }
849 }
850 }
851 }
852 state["ast"]["credentials"] = json!(allowed);
853 let state: PasskeyAuthentication = serde_json::from_value(state)?;
854 let result = auth
855 .webauthn
856 .finish_passkey_authentication(&credential, &state)
857 .map_err(|error| {
858 eprintln!("passkey authentication: {error:?}");
859 Error::new(
860 401,
861 "That passkey couldn't sign in. Try again or use your password.",
862 )
863 })?;
864 let id = string(&value["user"]);
865 {
866 let db = auth.db.lock().unwrap();
867 let user = user(&db, id)?;
868 if user["enabled"] != true {
869 return Err(Error::new(403, "This account is disabled. Contact Clover."));
870 }
871 let mut statement = db.prepare(
872 "SELECT id,data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
873 )?;
874 let rows = statement
875 .query_map([id], |r| {
876 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
877 })?
878 .collect::<std::result::Result<Vec<_>, _>>()?;
879 let mut matched = false;
880 for (key, data) in rows {
881 let mut data: Value = serde_json::from_str(&data)?;
882 let mut passkey: Passkey = serde_json::from_value(data["passkey"].clone())?;
883 if passkey.cred_id() == result.cred_id() {
884 if let Some(handle) = body["credential"]["response"]["userHandle"].as_str() {
885 if !handle.is_empty() && handle != string(&data["handle"]) {
886 return Err(Error::new(
887 401,
888 "That passkey belongs to a different account.",
889 ));
890 }
891 }
892 matched = true;
893 let current: Credential = passkey.clone().into();
894 if (current.counter != 0 || result.counter() != 0)
895 && result.counter() <= current.counter
896 {
897 return Err(Error::new(
898 401,
899 "This passkey returned an old counter. Try another sign-in method.",
900 ));
901 }
902 if data["backupUnknown"] == true {
903 let mut key: Credential = passkey.into();
904 key.backup_eligible = result.backup_eligible();
905 key.backup_state = result.backup_state();
906 passkey = key.into();
907 data.as_object_mut().unwrap().remove("backupUnknown");
908 }
909 passkey.update_credential(&result);
910 data["passkey"] = json!(passkey);
911 db.execute(
912 "UPDATE credentials SET data=? WHERE id=?",
913 sql![data.to_string(), key],
914 )?;
915 break;
916 }
917 }
918 if !matched {
919 return Err(Error::new(
920 401,
921 "This passkey was removed. Try another sign-in method.",
922 ));
923 }
924 }
925 let session = auth.create_session(id, "dashboard", &headers, None)?;
926 let next =
927 if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() {
928 "/account".into()
929 } else {
930 auth.next(id, string(&value["flow"]), string(&value["next"]))?
931 };
932 return Ok(([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response());
933 }
934 if path == "/auth/setup" {
935 let email = string(&body["email"]).trim();
936 if !email.contains('@') || email.len() > 254 {
937 return Err(Error::new(400, "Enter your email address."));
938 }
939 let password = string(&body["password"]).to_owned();
940 if password.chars().count() < 8 || password.len() > 1024 {
941 return Err(Error::new(
942 400,
943 "Use a password with at least 8 characters.",
944 ));
945 }
946 let _slot = auth
947 .passwords
948 .try_acquire()
949 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
950 let hash = tokio::task::spawn_blocking(move || password_hash(&password)).await??;
951 let id = {
952 let mut db = auth.db.lock().unwrap();
953 let transaction = db.transaction()?;
954 let entry = pending(&transaction, string(&body["setup"]), "setup", true)?;
955 if entry.is_null() {
956 return Err(Error::new(
957 410,
958 "This link expired. Ask Clover for a new one.",
959 ));
960 }
961 let id = string(&entry["user"]).to_owned();
962 let mut profile = user(&transaction, &id)?;
963 profile["email"] = json!(email);
964 profile["emailVerified"] = json!(false);
965 if profile["enabled"] != true {
966 return Err(Error::new(
967 403,
968 "This account is disabled. Ask Clover for a new link.",
969 ));
970 }
971 profile["requiredActions"] = json!([]);
972 set_password(&transaction, &id, &hash)?;
973 save_user(&transaction, &id, profile)?;
974 transaction.execute("DELETE FROM sessions WHERE user_id=?", [&id])?;
975 transaction.commit()?;
976 id
977 };
978 users::revoke_connections(&app, &id)?;
979 return Ok((
980 [(
981 "set-cookie",
982 auth.create_session(&id, "dashboard", &headers, None)?,
983 )],
984 axum::Json(json!({"next":"/account?welcome=1"})),
985 )
986 .into_response());
987 }
988 let user = auth.session(&headers, "dashboard")?;
989 if user.is_null() {
990 return Err(Error::new(401, "Sign in to manage your account."));
991 }
992 let id = string(&user["id"]);
993 if path == "/auth/passkey/register" {
994 auth.recent(&headers)?;
995 let db = auth.db.lock().unwrap();
996 let keys = passkeys(&db, id)?;
997 let ids = keys.iter().map(|key| key.cred_id().clone()).collect();
998 let uuid = uuid::Uuid::parse_str(id)?;
999 let (options, state) = auth.webauthn.start_passkey_registration(
1000 uuid,
1001 string(&user["username"]),
1002 string(&user["username"]),
1003 Some(ids),
1004 )?;
1005 let token = issue(
1006 &db,
1007 "registration",
1008 json!({"user":id,"csrf":body["csrf"],"state":state}),
1009 300,
1010 )?;
1011 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
1012 }
1013 if path == "/auth/passkey/save" {
1014 auth.recent(&headers)?;
1015 let db = auth.db.lock().unwrap();
1016 let value = pending(&db, string(&body["token"]), "registration", true)?;
1017 if value.is_null() || value["user"] != user["id"] || value["csrf"] != body["csrf"] {
1018 return Err(Error::new(403, "Passkey setup expired. Try again."));
1019 }
1020 let credential: RegisterPublicKeyCredential =
1021 serde_json::from_value(body["credential"].clone()).map_err(|_| {
1022 Error::new(400, "The browser couldn't create your passkey. Try again.")
1023 })?;
1024 let state: PasskeyRegistration = serde_json::from_value(value["state"].clone())?;
1025 let passkey = auth
1026 .webauthn
1027 .finish_passkey_registration(&credential, &state)
1028 .map_err(|_| Error::new(400, "That passkey couldn't be added. Try again."))?;
1029 let label = string(&body["label"]).trim();
1030 if label.len() > 100 {
1031 return Err(Error::new(400, "Use a shorter passkey name."));
1032 }
1033 db.execute("INSERT INTO credentials VALUES (?,?,?,?,?,?)",sql![uuid::Uuid::new_v4().to_string(),id,"webauthn-passwordless",if label.is_empty(){"passkey"}else{label},(now()*1000.0) as i64,json!({"passkey":passkey,"handle":URL_SAFE_NO_PAD.encode(uuid::Uuid::parse_str(id)?.as_bytes())}).to_string()])?;
1034 return Ok(StatusCode::NO_CONTENT.into_response());
1035 }
1036 if path == "/auth/password/change" {
1037 auth.recent(&headers)?;
1038 let data = row(
1039 &auth.db.lock().unwrap(),
1040 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
1041 id,
1042 )?;
1043 let phc = string(&data["phc"]).to_owned();
1044 let current = string(&body["current"]).to_owned();
1045 let password = string(&body["password"]).to_owned();
1046 if password.chars().count() < 8 || password.len() > 1024 || current.len() > 1024 {
1047 return Err(Error::new(
1048 400,
1049 "Use a password with at least 8 characters.",
1050 ));
1051 }
1052 auth.limit(&headers, id)?;
1053 let _slot = auth
1054 .passwords
1055 .try_acquire()
1056 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
1057 let hash = tokio::task::spawn_blocking(move || {
1058 if !phc.is_empty()
1059 && !PasswordHash::new(&phc).is_ok_and(|hash| {
1060 Argon2::default()
1061 .verify_password(current.as_bytes(), &hash)
1062 .is_ok()
1063 })
1064 {
1065 return Err(Error::new(
1066 401,
1067 "Your current password doesn't match. Try again.",
1068 ));
1069 }
1070 password_hash(&password)
1071 })
1072 .await??;
1073 {
1074 let mut db = auth.db.lock().unwrap();
1075 let transaction = db.transaction()?;
1076 let mut profile = self::user(&transaction, id)?;
1077 if profile["enabled"] != true {
1078 return Err(Error::new(403, "This account is disabled."));
1079 }
1080 set_password(&transaction, id, &hash)?;
1081 profile["requiredActions"] = json!(
1082 array(&user["requiredActions"])
1083 .iter()
1084 .filter(|v| **v != "UPDATE_PASSWORD")
1085 .collect::<Vec<_>>()
1086 );
1087 save_user(&transaction, id, profile)?;
1088 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
1089 transaction.commit()?;
1090 }
1091 users::revoke_connections(&app, id)?;
1092 return Ok((
1093 [(
1094 "set-cookie",
1095 auth.create_session(id, "dashboard", &headers, None)?,
1096 )],
1097 StatusCode::NO_CONTENT,
1098 )
1099 .into_response());
1100 }
1101 Err(Error::new(404, "No account action here."))
1102}
1103
1104fn passkeys(db: &Connection, id: &str) -> Result<Vec<Passkey>> {
1105 let mut statement = db
1106 .prepare("SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'")?;
1107 statement
1108 .query_map([id], |r| r.get::<_, String>(0))?
1109 .map(|data| {
1110 let value: Value = serde_json::from_str(&data?)?;
1111 Ok(serde_json::from_value(value["passkey"].clone())?)
1112 })
1113 .collect()
1114}
dashboard/src/cache.rs-73
......@@ -51,34 +51,6 @@ impl Cache {
5151 Ok(entry)
5252 }
5353
54 pub async fn coalesce<F, Fut>(&self, key: String, load: F) -> Result<Arc<Document>>
55 where
56 F: FnOnce() -> Fut + Send + 'static,
57 Fut: Future<Output = Result<serde_json::Value>> + Send + 'static,
58 {
59 let started = Instant::now();
60 let entry = self.entry(key)?;
61 let guard = entry.loading.clone().lock_owned().await;
62 {
63 let state = entry.state.lock().unwrap();
64 if let Some((at, value)) = &state.value
65 && *at >= started
66 {
67 return Ok(value.clone());
68 }
69 if let Some((at, error)) = &state.failure
70 && *at >= started
71 {
72 return Err(error.clone());
73 }
74 }
75 tokio::spawn(async move {
76 let _guard = guard;
77 entry.store(load().await)
78 })
79 .await?
80 }
81
8254 pub fn invalidate(&self, key: &str) {
8355 self.0.lock().unwrap().remove(key);
8456 }
......@@ -180,51 +152,6 @@ mod tests {
180152 use super::*;
181153 use std::sync::atomic::{AtomicUsize, Ordering};
182154 #[tokio::test]
183 async fn coalesced_identity_reads_do_not_reuse_completed_or_failed_results() {
184 let cache = Arc::new(Cache::default());
185 let calls = Arc::new(AtomicUsize::new(0));
186 let mut readers = Vec::new();
187 for _ in 0..100 {
188 let (cache, calls) = (cache.clone(), calls.clone());
189 readers.push(tokio::spawn(async move {
190 cache
191 .coalesce("identity:owner".into(), move || async move {
192 calls.fetch_add(1, Ordering::SeqCst);
193 tokio::time::sleep(Duration::from_millis(20)).await;
194 Ok(serde_json::json!({"enabled":true}))
195 })
196 .await
197 .unwrap()
198 }));
199 }
200 for reader in readers {
201 assert_eq!(reader.await.unwrap().value["enabled"], true);
202 }
203 assert_eq!(calls.load(Ordering::SeqCst), 1);
204 let disabled = cache
205 .coalesce("identity:owner".into(), || async {
206 Ok(serde_json::json!({"enabled":false}))
207 })
208 .await
209 .unwrap();
210 assert_eq!(disabled.value["enabled"], false);
211 assert!(
212 cache
213 .coalesce("identity:owner".into(), || async {
214 Err(Error::new(502, "unavailable"))
215 })
216 .await
217 .is_err()
218 );
219 let recovered = cache
220 .coalesce("identity:owner".into(), || async {
221 Ok(serde_json::json!({"enabled":true}))
222 })
223 .await
224 .unwrap();
225 assert_eq!(recovered.value["enabled"], true);
226 }
227 #[tokio::test]
228155 async fn disconnecting_reader_does_not_cancel_shared_load() {
229156 let cache = Arc::new(Cache::default());
230157 let started = Arc::new(tokio::sync::Notify::new());
dashboard/src/core.rs+7-2
......@@ -839,7 +839,9 @@ mod tests {
839839 #[tokio::test]
840840 async fn launcher_excludes_directories_and_grouped_definitions_before_import() {
841841 let root = std::env::temp_dir().join(format!("studio-launcher-{}", uuid::Uuid::new_v4()));
842 tokio::fs::create_dir_all(root.join("config")).await.unwrap();
842 tokio::fs::create_dir_all(root.join("config"))
843 .await
844 .unwrap();
843845 for directory in ["retired", "personal"] {
844846 tokio::fs::create_dir_all(root.join("service").join(directory))
845847 .await
......@@ -864,7 +866,10 @@ mod tests {
864866 assert!(module.contains("/personal/service.pkl"));
865867 assert!(module.contains("/personal/fixture.pkl"));
866868 assert_eq!(
867 module.lines().filter(|line| line.starts_with("import ")).count(),
869 module
870 .lines()
871 .filter(|line| line.starts_with("import "))
872 .count(),
868873 2,
869874 );
870875 tokio::fs::remove_dir_all(root).await.unwrap();
dashboard/src/main.rs+113-2
......@@ -1,4 +1,5 @@
11mod apps;
2mod auth;
23mod cache;
34mod core;
45mod deploys;
......@@ -82,6 +83,7 @@ impl Document {
8283}
8384
8485struct App {
86 auth: auth::Store,
8587 mcp: mcp::Store,
8688 relay: relay::Broker,
8789 shale: shale::Backend,
......@@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
388390 "STUDIO_PUBLIC_ORIGIN",
389391 &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")),
390392 );
393 let auth = auth::Store::new(
394 &PathBuf::from(env("STUDIO_DATA_DIR", "data")),
395 &origin,
396 &env(
397 "STUDIO_FILE_ORIGIN",
398 &format!("https://file.{}", env("STUDIO_DOMAIN", "studio.test")),
399 ),
400 &env(
401 "STUDIO_AUTH_RP_ID",
402 &format!("auth.{}", env("STUDIO_DOMAIN", "studio.test")),
403 ),
404 )
405 .map_err(|error| std::io::Error::other(error.message))?;
406 if let Some(path) = std::env::args().skip(1).next() {
407 if path != "--import-accounts" {
408 return Err(std::io::Error::other("Unknown dashboard argument.").into());
409 }
410 let path = std::env::args()
411 .nth(2)
412 .ok_or_else(|| std::io::Error::other("Provide an account export path."))?;
413 let result = auth
414 .import(serde_json::from_slice(&std::fs::read(path)?)?)
415 .map_err(|error| std::io::Error::other(error.message))?;
416 println!("{result}");
417 return Ok(());
418 }
419 let import = PathBuf::from(env("STUDIO_DATA_DIR", "data")).join("accounts-import.json");
420 if import.exists() {
421 auth.import(serde_json::from_slice(&std::fs::read(&import)?)?)
422 .map_err(|error| std::io::Error::other(error.message))?;
423 std::fs::remove_file(&import)?;
424 }
425 if env("STUDIO_AUTH_REQUIRED", "0") == "1" && !auth.ready() {
426 return Err(std::io::Error::other(
427 "Import accounts before starting native authentication.",
428 )
429 .into());
430 }
391431 let app = Arc::new(App {
432 auth,
392433 mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin)
393434 .map_err(|error| std::io::Error::other(error.message))?,
394435 relay: relay::Broker::default(),
......@@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
437478 let dist = env("STUDIO_WEB_DIR", "dist");
438479 let router = Router::new()
439480 .route("/api/{*path}", any(api))
481 .route("/auth/{*path}", any(auth::route))
440482 .route("/oauth/{*path}", any(mcp::oauth))
441483 .route("/.well-known/{*path}", any(mcp::oauth))
442484 .nest_service("/assets", ServeDir::new(format!("{dist}/assets")))
443485 .with_state(app.clone())
444486 .merge(observability::router(app.clone()))
445487 .merge(shale::router(app.clone()))
446 .merge(relay::router(app))
488 .merge(relay::router(app.clone()))
447489 .fallback_service(
448490 ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))),
449491 )
450492 .layer(axum::middleware::from_fn(
451493 move |mut request: Request, next: axum::middleware::Next| {
452494 let proof = proof.clone();
495 let app = app.clone();
453496 async move {
454497 if mcp::public(request.uri().path()) {
455498 request.headers_mut().remove("Studio-Proxy-Token");
......@@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
467510 }
468511 request.headers_mut().remove("Studio-Proxy-Token");
469512 }
470 let asset = request.uri().path().starts_with("/assets/");
513 let path = request.uri().path().to_owned();
514 let asset = path.starts_with("/assets/");
515 if app.auth.ready()
516 && !mcp::public(&path)
517 && !path.starts_with("/auth/")
518 && !asset
519 && path != "/sign-in"
520 {
521 request.headers_mut().remove("User-Name");
522 request.headers_mut().remove("User-Groups");
523 let account = match app.auth.session(request.headers(), "dashboard") {
524 Ok(account) => account,
525 Err(error) => return error.into_response(),
526 };
527 if account.is_null() {
528 return if path.starts_with("/api/") {
529 Error::new(401, "Sign in to Snowglobe.").into_response()
530 } else {
531 (
532 StatusCode::FOUND,
533 [(
534 "location",
535 format!(
536 "/sign-in?next={}",
537 encoded(&request.uri().to_string())
538 ),
539 )],
540 )
541 .into_response()
542 };
543 }
544 if !matches!(
545 *request.method(),
546 Method::GET | Method::HEAD | Method::OPTIONS
547 ) && request
548 .headers()
549 .get("origin")
550 .and_then(|v| v.to_str().ok())
551 != Some(app.auth.origin.origin().ascii_serialization().as_str())
552 {
553 return Error::new(403, "Open Snowglobe and try again.")
554 .into_response();
555 }
556 if !array(&account["requiredActions"]).is_empty()
557 && !path.starts_with("/api/account")
558 && path.starts_with("/api/")
559 && path != "/api/me"
560 {
561 return Error::new(
562 403,
563 "Change your temporary password in your account first.",
564 )
565 .into_response();
566 }
567 let groups = array(&account["groups"])
568 .iter()
569 .map(|v| string(&v["name"]))
570 .collect::<Vec<_>>()
571 .join(",");
572 request
573 .headers_mut()
574 .insert("User-Name", string(&account["username"]).parse().unwrap());
575 request
576 .headers_mut()
577 .insert("User-Groups", groups.parse().unwrap());
578 }
471579 let document = !asset && !request.uri().path().starts_with("/api/");
472580 if document {
473581 request.headers_mut().remove("if-modified-since");
474582 request.headers_mut().remove("if-none-match");
475583 }
476584 let mut response = next.run(request).await;
585 response.headers_mut().insert("referrer-policy", "no-referrer".parse().unwrap());
586 response.headers_mut().insert("x-content-type-options", "nosniff".parse().unwrap());
587 response.headers_mut().insert("x-frame-options", "DENY".parse().unwrap());
477588 if asset && response.status().is_success() {
478589 response.headers_mut().insert(
479590 "cache-control",
dashboard/src/mcp.rs+143-47
......@@ -445,7 +445,7 @@ impl Store {
445445 tx.commit()?;
446446 return Ok((
447447 StatusCode::FOUND,
448 [("location", format!("/mcp?request={}", encoded(&pending)))],
448 [("location", format!("/connect/{}", encoded(&pending)))],
449449 )
450450 .into_response());
451451 }
......@@ -490,6 +490,16 @@ impl Store {
490490
491491#[derive(Clone)]
492492pub(crate) struct Grant(pub Value);
493pub(crate) fn active_owner(app: &App, grant: &Value) -> Result<bool> {
494 let profile = match auth::user(&app.auth.db.lock().unwrap(), string(&grant["user"])) {
495 Ok(profile) => profile,
496 Err(error) if error.status == 404 => return Ok(false),
497 Err(error) => return Err(error),
498 };
499 Ok(profile["enabled"] == true
500 && (grant["resource"] != app.mcp.resource("observability")
501 || array(&profile["groups"]).iter().any(|role| role["name"] == "infra-admin")))
502}
493503pub fn router<H: rmcp::ServerHandler>(
494504 app: Arc<App>,
495505 catalog: &str,
......@@ -529,7 +539,9 @@ pub fn router<H: rmcp::ServerHandler>(
529539 return Error::new(403, "This origin cannot use the connector.")
530540 .into_response();
531541 }
532 match app.mcp.authenticate(request.headers(), &resource) {
542 match app.mcp.authenticate(request.headers(), &resource).and_then(|grant| {
543 if active_owner(&app, &grant)? { Ok(grant) } else { Err(Error::new(401, "invalid_token")) }
544 }) {
533545 Ok(grant) => {
534546 request.extensions_mut().insert(Grant(grant));
535547 if let Some(value) = request.headers_mut().get_mut("authorization") {
......@@ -554,10 +566,16 @@ pub fn router<H: rmcp::ServerHandler>(
554566
555567pub fn public(path: &str) -> bool {
556568 path.starts_with("/oauth/")
557 || path.starts_with("/mcp/")
569 || CATALOGS.iter().any(|(id, _, _)| {
570 path == format!("/mcp/{id}") || path.starts_with(&format!("/mcp/{id}/"))
571 })
558572 || path.starts_with("/.well-known/oauth-")
559573 || path == "/pairing"
560574 || path == "/agent/connect"
575 || matches!(
576 path,
577 "/agent/install.sh" | "/agent/install.ps1" | "/agent/setup.mjs" | "/agent/relay.mjs"
578 )
561579 || path.starts_with("/api/v1/")
562580}
563581pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {
......@@ -590,19 +608,7 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {
590608 let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null};
591609 if path == "/oauth/token" && method == Method::POST {
592610 let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?;
593 let id = string(&grant["user"]);
594 let (profile, roles) = match tokio::try_join!(
595 host::call(json!({"operation":"iam.request","path":format!("/users/{id}"),"method":"GET","body":null})),
596 host::call(json!({"operation":"iam.request","path":format!("/users/{id}/role-mappings/realm"),"method":"GET","body":null}))
597 ) {
598 Ok(identity) => identity,
599 Err(error) if error.status == 404 => {
600 revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?;
601 return Err(fail("invalid_grant"));
602 }
603 Err(error) => return Err(error),
604 };
605 if profile["body"]["enabled"] != true || grant["resource"] == app.mcp.resource("observability") && !array(&roles["body"]).iter().any(|role| role["name"] == "infra-admin") {
611 if !active_owner(&app, &grant)? {
606612 revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?;
607613 return Err(fail("invalid_grant"));
608614 }
......@@ -633,6 +639,25 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response {
633639 response
634640}
635641
642fn chosen_resources(body: &Value, resources: &[Value], shale: bool) -> Result<Value> {
643 if shale && body["resources"] == "all" {
644 return Ok(json!("all"));
645 }
646 let chosen = body["resources"]
647 .as_array()
648 .filter(|items| !items.is_empty() && items.len() <= resources.len())
649 .ok_or_else(|| Error::new(400, "Choose each available resource once."))?;
650 if chosen.iter().enumerate().any(|(index, item)| {
651 !resources.iter().any(|resource| item == &resource["id"]) || chosen[..index].contains(item)
652 }) {
653 return Err(Error::new(
654 403,
655 "Choose resources available to your account.",
656 ));
657 }
658 Ok(json!(chosen))
659}
660
636661pub async fn manage(
637662 app: Arc<App>,
638663 method: &Method,
......@@ -672,32 +697,66 @@ pub async fn manage(
672697 .keep_alive(axum::response::sse::KeepAlive::default())
673698 .into_response());
674699 }
675 let consent_resource = if let ["consent", id] = parts {
676 get(
700 let consent_resource = if let ["connections", id] = parts
701 && method != Method::DELETE
702 {
703 let grant = get(&app.mcp.db.lock().unwrap(), &format!("grant:{id}"))?;
704 if grant["user"] != owner_id {
705 return Err(Error::new(404, "No connection with that ID."));
706 }
707 string(&grant["resource"]).to_owned()
708 } else if let ["consent", id] = parts {
709 let pending = get(
677710 &app.mcp.db.lock().unwrap(),
678711 &format!("pending:{}", hash(id)),
679 )?["resource"]
680 .as_str()
681 .unwrap_or_default()
682 .to_owned()
712 )?;
713 if pending.is_null() {
714 return Err(Error::new(
715 404,
716 "This connection request expired. Start it again.",
717 ));
718 }
719 if !pending["owner"].is_null() && pending["owner"] != owner_id {
720 return Err(Error::new(
721 403,
722 "This connection request belongs to another account.",
723 ));
724 }
725 string(&pending["resource"]).to_owned()
683726 } else {
684727 String::new()
685728 };
686729 let agent_consent = consent_resource == app.mcp.resource("agents");
687730 let shale_consent = consent_resource == app.mcp.resource("shale");
731 let catalog = CATALOGS
732 .iter()
733 .find(|(id, _, _)| consent_resource == app.mcp.resource(id))
734 .map(|(id, _, _)| *id);
688735 let mut linked = true;
689 let resources: Vec<Value> = if agent_consent {
736 let mut resource_error = None;
737 let resources: Vec<Value> = if body["deny"] == true {
738 Vec::new()
739 } else if agent_consent {
690740 relay::machines(&app.mcp.db.lock().unwrap(), owner_id)?
691741 .into_iter()
692742 .map(|m| json!({"id":m["id"],"name":m["name"]}))
693743 .collect()
694 } else if shale_consent && body["deny"] != true {
695 match shale::repositories(&app, owner_id).await {
744 } else if shale_consent {
745 let available = if body["resources"] == "all" {
746 shale::verified_session(&app, owner_id).await.map(|_| Vec::new())
747 } else {
748 shale::repositories(&app, owner_id).await
749 };
750 match available {
696751 Ok(repositories) => repositories,
697752 Err(error) if error.status == 401 => {
698753 linked = false;
699754 Vec::new()
700755 }
756 Err(error) if method == Method::GET => {
757 resource_error = Some(error.message);
758 Vec::new()
759 }
701760 Err(error) => return Err(error),
702761 }
703762 } else if consent_resource == app.mcp.resource("observability")
......@@ -726,13 +785,14 @@ pub async fn manage(
726785 let machines = relay::machines(&tx, owner_id)?;
727786 let connections = grants.iter().map(|grant| {
728787 let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()};
729 let resources: Vec<_> = array(&grant["resources"]).iter().map(|id| if grant["resource"] == app.mcp.resource("agents") {machines.iter().find(|m| m["id"] == *id).map(|m| m["name"].clone()).unwrap_or_else(|| json!("Unlinked machine"))} else {id.clone()}).collect();
730 Ok(json!({"id":grant["id"],"name":name,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]}))
788 let resources = if grant["resource"] == app.mcp.resource("shale") && grant["resources"] == "all" {json!("all")} else {json!(array(&grant["resources"]).iter().map(|id| if grant["resource"] == app.mcp.resource("agents") {machines.iter().find(|m| m["id"] == *id).map(|m| m["name"].clone()).unwrap_or_else(|| json!("Unlinked machine"))} else {id.clone()}).collect::<Vec<_>>())};
789 let catalog = CATALOGS.iter().find(|(id, _, _)| grant["resource"] == app.mcp.resource(id)).map(|(id, _, _)| *id);
790 Ok(json!({"id":grant["id"],"name":name,"catalog":catalog,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]}))
731791 }).collect::<Result<Vec<_>>>()?;
732792 let shale = get(&tx, &format!("shale-session:{owner_id}"))?;
733793 let catalogs: Vec<_> = CATALOGS
734794 .iter()
735 .map(|(id, name, _)| json!({"name":name,"endpoint":app.mcp.resource(id)}))
795 .map(|(id, name, _)| json!({"id":id,"name":name,"endpoint":app.mcp.resource(id)}))
736796 .collect();
737797 json!({"catalogs":catalogs,"connections":connections,"machines":app.relay.view(machines,None),"shale":if shale["origin"] != app.shale.origin.as_str() {Value::Null} else {json!({"linkedAt":shale["linkedAt"]})}})
738798 }
......@@ -770,29 +830,19 @@ pub async fn manage(
770830 "UPDATE records SET value=? WHERE key=?",
771831 rusqlite::params![pending.to_string(), key],
772832 )?;
773 json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"scopes":pending["scopes"],"resources":resources,"linked":linked})
833 json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"catalog":catalog,"account":owner["username"],"redirectHost":redirect(string(&pending["redirect"]))?.host_str(),"scopes":pending["scopes"],"resources":resources,"linked":linked,"resourceError":resource_error})
774834 } else {
775835 if shale_consent
776 && get(&tx, &format!("shale-session:{owner_id}"))?["origin"]
777 != app.shale.origin.as_str()
836 && (!linked
837 || get(&tx, &format!("shale-session:{owner_id}"))?["origin"]
838 != app.shale.origin.as_str())
778839 {
779840 return Err(Error::new(
780841 401,
781842 "Link your Shale account before allowing repository access.",
782843 ));
783844 }
784 let chosen = body["resources"]
785 .as_array()
786 .filter(|a| !a.is_empty() && a.len() <= resources.len())
787 .ok_or_else(|| Error::new(400, "Choose each available resource once."))?;
788 if chosen.iter().enumerate().any(|(index, r)| {
789 !resources.iter().any(|id| r == &id["id"]) || chosen[..index].contains(r)
790 }) {
791 return Err(Error::new(
792 403,
793 "Choose resources available to your account.",
794 ));
795 }
845 let chosen = chosen_resources(&body, &resources, shale_consent)?;
796846 if list(&tx, "grant:")?
797847 .iter()
798848 .filter(|g| g["user"] == owner_id)
......@@ -826,13 +876,37 @@ pub async fn manage(
826876 }
827877 }
828878 ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?,
829 ["connections", id] if method == Method::DELETE => {
830 let grant = get(&tx, &format!("grant:{id}"))?;
879 ["connections", id]
880 if method == Method::GET || method == Method::POST || method == Method::DELETE =>
881 {
882 let key = format!("grant:{id}");
883 let mut grant = get(&tx, &key)?;
831884 if grant["user"] != owner_id {
832885 return Err(Error::new(404, "No connection with that ID."));
833886 }
834 revoke(&tx, id)?;
835 Value::Null
887 if method == Method::DELETE {
888 revoke(&tx, id)?;
889 Value::Null
890 } else if method == Method::GET {
891 json!({"resources": resources, "selected": grant["resources"], "linked": linked,"resourceError":resource_error})
892 } else {
893 if shale_consent && !linked {
894 return Err(Error::new(
895 401,
896 "Link your Shale account before allowing repository access.",
897 ));
898 }
899 let chosen = chosen_resources(&body, &resources, shale_consent)?;
900 grant["resources"] = json!(chosen);
901 if agent_consent {
902 grant["targets"] = json!(chosen);
903 }
904 tx.execute(
905 "UPDATE records SET value=? WHERE key=?",
906 rusqlite::params![grant.to_string(), key],
907 )?;
908 Value::Null
909 }
836910 }
837911 _ => return Err(Error::new(404, "No endpoint here.")),
838912 };
......@@ -849,6 +923,28 @@ pub async fn manage(
849923
850924#[cfg(test)]
851925mod tests {
926 #[test]
927 fn resource_updates_reject_empty_duplicates_and_foreign_choices() {
928 let resources = vec![json!({"id":"alpha"}), json!({"id":"beta"})];
929 assert_eq!(
930 chosen_resources(&json!({"resources":["beta"]}), &resources, false).unwrap(),
931 json!(["beta"])
932 );
933 for selected in [
934 json!([]),
935 json!(["alpha", "alpha"]),
936 json!(["foreign"]),
937 json!([null]),
938 ] {
939 assert!(chosen_resources(&json!({"resources":selected}), &resources, false).is_err());
940 }
941 assert_eq!(
942 chosen_resources(&json!({"resources":"all"}), &[], true).unwrap(),
943 json!("all")
944 );
945 assert!(chosen_resources(&json!({"resources":"all"}), &resources, false).is_err());
946 }
947
852948 use super::*;
853949 struct Fixture {
854950 store: Arc<Store>,
dashboard/src/relay.rs+27
......@@ -618,6 +618,9 @@ async fn rest(State(app): State<Arc<App>>, request: Request) -> Response {
618618 let grant = app
619619 .mcp
620620 .authenticate(request.headers(), &app.mcp.resource("agents"))?;
621 if !mcp::active_owner(&app, &grant)? {
622 return Err(Error::new(401, "This connection's account is no longer authorized."));
623 }
621624 let id = string(&grant["id"]);
622625 let method = request.method().clone();
623626 let path = request
......@@ -778,13 +781,37 @@ impl ServerHandler for Agents {
778781 .into())
779782 }
780783}
784async fn installer(State(app): State<Arc<App>>, request: Request) -> Response {
785 let origin = app.mcp.origin.origin().ascii_serialization();
786 let source = if request.uri().path().ends_with(".ps1") {
787 include_str!("../agent/install.ps1")
788 .replace("__SERVER__", &format!("'{}'", origin.replace('\'', "''")))
789 } else {
790 include_str!("../agent/install.sh").replace(
791 "__SERVER__",
792 &format!("'{}'", origin.replace('\'', "'\\''")),
793 )
794 };
795 ([("content-type", "text/plain; charset=utf-8")], source).into_response()
796}
781797pub fn router(app: Arc<App>) -> Router {
782798 let state = app.clone();
783799 let expected_host =
784800 app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned();
801 let agent = env("STUDIO_AGENT_DIR", "agent");
785802 Router::new()
786803 .route("/pairing", any(pairing))
787804 .route("/agent/connect", any(connect))
805 .route("/agent/install.sh", axum::routing::get(installer))
806 .route("/agent/install.ps1", axum::routing::get(installer))
807 .route_service(
808 "/agent/setup.mjs",
809 ServeFile::new(format!("{agent}/setup.mjs")),
810 )
811 .route_service(
812 "/agent/relay.mjs",
813 ServeFile::new(format!("{agent}/relay.mjs")),
814 )
788815 .route("/api/v1/{*path}", any(rest))
789816 .with_state(app.clone())
790817 .merge(mcp::router(app, "agents", move || {
dashboard/src/shale.rs+41-11
......@@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String {
149149fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> {
150150 if repository.is_empty()
151151 || repository.len() > 255
152 || matches!(repository, "." | ".." | "-")
152 || repository
153 .split('/')
154 .any(|part| matches!(part, "" | "." | ".." | "-"))
153155 || repository
154156 .chars()
155 .any(|c| c.is_control() || c.is_whitespace() || "/\\%?#".contains(c))
157 .any(|c| c.is_control() || c.is_whitespace() || "\\%?#".contains(c))
156158 {
157159 return Err(Error::new(
158160 400,
......@@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu
164166 .path_segments_mut()
165167 .unwrap()
166168 .clear()
167 .push(repository)
169 .extend(repository.split('/'))
168170 .extend(suffix.iter().copied());
169171 Ok(target)
170172}
......@@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result<String> {
184186 .map(str::to_owned)
185187 .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab."))
186188}
187pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> {
189pub(crate) async fn verified_session(app: &App, owner: &str) -> Result<String> {
188190 let session = session(app, owner)?;
189191 username(
190192 &app.shale
191193 .page(&app.shale.origin.join("/-/settings")?, &session)
192194 .await?,
193195 )?;
196 Ok(session)
197}
198pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> {
199 let session = verified_session(app, owner).await?;
194200 let body = app.shale.page(&app.shale.origin, &session).await?;
195201 let document = document(&body, "page-index", None)?;
196202 let mut repositories = Vec::new();
......@@ -359,12 +365,12 @@ impl ServerHandler for Shale {
359365 current(app, grant, &credential)?;
360366 if name == "list_repositories" {
361367 let mut repositories = repositories(app, string(&grant["user"])).await?;
362 repositories.retain(|r| array(&grant["resources"]).contains(&r["id"]));
368 repositories.retain(|r| grant["resources"] == "all" || array(&grant["resources"]).contains(&r["id"]));
363369 current(app, grant, &credential)?;
364370 return Ok(json!({"repositories":repositories}));
365371 }
366372 let repository = arguments.get("repository").and_then(Value::as_str)
367 .filter(|r| array(&grant["resources"]).iter().any(|id| id == *r))
373 .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r))
368374 .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?;
369375 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;
370376 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {
......@@ -556,6 +562,15 @@ pub async fn manage(
556562 let owner_id = string(&owner["id"]);
557563 let key = format!("shale-session:{owner_id}");
558564 match *method {
565 Method::GET => match repositories(&app, owner_id).await {
566 Ok(resources) => {
567 Ok(axum::Json(json!({"linked":true,"resources":resources})).into_response())
568 }
569 Err(error) if error.status == 401 => {
570 Ok(axum::Json(json!({"linked":false,"resources":[]})).into_response())
571 }
572 Err(error) => Err(error),
573 },
559574 Method::POST => {
560575 let pending = if let Some(id) = body["request"].as_str() {
561576 let db = app.mcp.db.lock().unwrap();
......@@ -740,10 +755,10 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
740755 let _ = app.shale.get("/-/logout", Some(&session)).await;
741756 return Err(error);
742757 }
743 let mut target = app.mcp.origin.join("mcp")?;
744 if let Some(request) = link["request"].as_str() {
745 target.query_pairs_mut().append_pair("request", request);
746 }
758 let target = app.mcp.origin.join(&match link["request"].as_str() {
759 Some(request) => format!("connect/{request}"),
760 None => "mcp/settings/shale".to_owned(),
761 })?;
747762 Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response())
748763 }.await;
749764 let mut response = match result {
......@@ -779,7 +794,11 @@ mod tests {
779794 "..",
780795 "-",
781796 "../other",
782 "one/two",
797 "one//two",
798 "one/../two",
799 "one/./two",
800 "one/-/two",
801 "one/",
783802 "one\\two",
784803 "%2e%2e",
785804 "one?x",
......@@ -795,6 +814,17 @@ mod tests {
795814 let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap();
796815 assert_eq!(path.origin(), origin.origin());
797816 assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1");
817 let path = repository_path(
818 &origin,
819 "userscripts/discord-pluralkit-predict",
820 &["issues", "1"],
821 )
822 .unwrap();
823 assert_eq!(path.origin(), origin.origin());
824 assert_eq!(
825 path.path(),
826 "/userscripts/discord-pluralkit-predict/issues/1"
827 );
798828 }
799829 #[test]
800830 fn issue_pages_must_match_repository_identity_and_issue_number() {
dashboard/src/telemetry.rs+19-5
......@@ -823,12 +823,20 @@ pub fn start(app: Arc<App>) {
823823 .unwrap()
824824 .iter()
825825 .flat_map(|(id, job)| {
826 array(&job["job"]["TaskGroups"]).iter()
826 array(&job["job"]["TaskGroups"])
827 .iter()
827828 .flat_map(|group| array(&group["Services"]))
828829 .flat_map(move |service| {
829830 array(&service["Tags"]).iter().filter_map(move |tag| {
830 string(tag).strip_prefix("studio-metrics-path=")
831 .map(|path| (id.clone(), string(&service["Name"]).to_owned(), path.to_owned()))
831 string(tag).strip_prefix("studio-metrics-path=").map(
832 |path| {
833 (
834 id.clone(),
835 string(&service["Name"]).to_owned(),
836 path.to_owned(),
837 )
838 },
839 )
832840 })
833841 })
834842 })
......@@ -842,7 +850,10 @@ pub fn start(app: Arc<App>) {
842850 let response = app
843851 .request(
844852 Method::GET,
845 &format!("{}{path}", endpoint(app.clone(), &service).await?),
853 &format!(
854 "{}{path}",
855 endpoint(app.clone(), &service).await?
856 ),
846857 )?
847858 .timeout(Duration::from_secs(5))
848859 .send()
......@@ -852,7 +863,10 @@ pub fn start(app: Arc<App>) {
852863 Method::POST,
853864 &format!(
854865 "{base}/api/v1/import/prometheus?{}",
855 params(&[("extra_label", format!("service={id}")), ("extra_label", format!("instance={service}"))])
866 params(&[
867 ("extra_label", format!("service={id}")),
868 ("extra_label", format!("instance={service}"))
869 ])
856870 ),
857871 )?
858872 .body(response.text().await?)
dashboard/src/users.rs+264-369
......@@ -1,85 +1,7 @@
11use crate::*;
22use axum::extract::{FromRequest, Multipart};
3use futures::{StreamExt, stream};
3use rusqlite::{OptionalExtension, params as sql};
44
5async fn call(path: &str, method: Method, body: Option<Value>) -> Result<Value> {
6 host::call(json!({"operation":"iam.request", "path":path,
7 "method":method.as_str(), "body":body}))
8 .await
9}
10async fn get(path: &str) -> Result<Value> {
11 Ok(call(path, Method::GET, None).await?["body"].take())
12}
13async fn list() -> Result<Value> {
14 let list = get("/users?max=1000").await?;
15 let found = stream::iter(array(&list).iter().cloned())
16 .map(|mut user| async move {
17 user["groups"] = get(&format!(
18 "/users/{}/role-mappings/realm",
19 encoded(string(&user["id"]))
20 ))
21 .await?;
22 for key in ["email", "firstName", "lastName"] {
23 if user.get(key).is_none() {
24 user[key] = Value::Null;
25 }
26 }
27 Ok::<_, Error>(user)
28 })
29 .buffered(4)
30 .collect::<Vec<_>>()
31 .await
32 .into_iter()
33 .collect::<Result<Vec<_>>>()?;
34 Ok(json!(found))
35}
36async fn directory(app: Arc<App>) -> Result<Arc<Document>> {
37 app.cache
38 .get(
39 "users".into(),
40 Duration::from_secs(300),
41 move || async move {
42 let (list, groups) = tokio::try_join!(list(), get("/roles"))?;
43 let users = stream::iter(array(&list).iter().cloned())
44 .map(|mut user| async move {
45 user["sessions"] =
46 get(&format!("/users/{}/sessions", encoded(string(&user["id"]))))
47 .await?;
48 Ok::<_, Error>(user)
49 })
50 .buffered(4)
51 .collect::<Vec<_>>()
52 .await
53 .into_iter()
54 .collect::<Result<Vec<_>>>()?;
55 Ok(json!({"users":users,"groups":groups}))
56 },
57 )
58 .await
59}
60async fn found(id: &str) -> Result<Value> {
61 array(&list().await?)
62 .iter()
63 .find(|u| u["id"] == id)
64 .cloned()
65 .ok_or_else(|| Error::new(404, "No user with that id"))
66}
67async fn spare(me: &Value, id: &str, remove_role: Option<&str>) -> Result<()> {
68 let user = found(id).await?;
69 if user["username"] == me["name"] {
70 let keeps_admin = remove_role.is_some()
71 && array(&user["groups"])
72 .iter()
73 .any(|g| g["name"] == "infra-admin" && g["name"] != remove_role.unwrap());
74 if !keeps_admin {
75 return Err(Error::new(
76 400,
77 "That would lock you out of this page. Sign in as another admin to change it.",
78 ));
79 }
80 }
81 Ok(())
82}
835fn uuid(id: &str) -> Result<()> {
846 if regex::Regex::new(
857 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
......@@ -113,7 +35,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
11335 let value = match key {
11436 "username" => {
11537 let v = string(value).trim().to_lowercase();
116 if !username_pattern.is_match(&v) {
38 if v.len() > 254 || !username_pattern.is_match(&v) {
11739 return Err(Error::new(
11840 400,
11941 "Usernames use lowercase letters, digits, dots, dashes, and @",
......@@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
12648 .as_str()
12749 .ok_or_else(|| Error::new(400, "Enter a name or email address."))?
12850 .trim();
51 if v.len() > 254 {
52 return Err(Error::new(400, "Use a shorter name or email address."));
53 }
12954 if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) {
13055 return Err(Error::new(400, "Enter a full email address"));
13156 }
......@@ -138,7 +63,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
13863 value.clone()
13964 }
14065 _ => {
141 if !value.is_array() || array(value).iter().any(|v| !v.is_string()) {
66 if !value.is_array() || array(value).iter().any(|v| v != "UPDATE_PASSWORD") {
14267 return Err(Error::new(400, "Invalid required actions."));
14368 }
14469 value.clone()
......@@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
15176fn password(value: &Value) -> Result<&str> {
15277 value
15378 .as_str()
154 .filter(|s| s.chars().count() >= 8)
79 .filter(|s| s.chars().count() >= 8 && s.len() <= 1024)
15580 .ok_or_else(|| Error::new(400, "Use at least 8 characters"))
15681}
15782
83pub(crate) fn revoke_connections(app: &App, id: &str) -> Result<()> {
84 let mut db = app.mcp.db.lock().unwrap();
85 let transaction = db.transaction()?;
86 for grant in mcp::list(&transaction, "grant:")? {
87 if grant["user"] == id {
88 mcp::revoke(&transaction, string(&grant["id"]))?;
89 }
90 }
91 transaction.execute(
92 "DELETE FROM records WHERE json_extract(value,'$.owner')=?",
93 [id],
94 )?;
95 transaction.commit()?;
96 Ok(())
97}
98
99pub async fn self_user(app: &App, me: &Value) -> Result<Value> {
100 let db = app.auth.db.lock().unwrap();
101 let id: Option<String> = db
102 .query_row(
103 "SELECT id FROM users WHERE username=?",
104 [string(&me["name"])],
105 |r| r.get(0),
106 )
107 .optional()?;
108 auth::user(
109 &db,
110 &id.ok_or_else(|| Error::new(401, "Sign in again to open your account."))?,
111 )
112}
113
158114pub async fn route(
159115 app: Arc<App>,
160116 method: &Method,
......@@ -163,142 +119,207 @@ pub async fn route(
163119 body: Value,
164120) -> Result<Response> {
165121 if parts.is_empty() && method == Method::GET {
166 return Ok(directory(app).await?.response());
167 }
168 if let Some(id) = parts.first() {
169 uuid(id)?;
122 let db = app.auth.db.lock().unwrap();
123 let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?;
124 let ids = statement
125 .query_map([], |r| r.get::<_, String>(0))?
126 .collect::<std::result::Result<Vec<_>, _>>()?;
127 let users = ids
128 .iter()
129 .map(|id| {
130 let mut user = auth::user(&db, id)?;
131 user["sessions"] = auth::Store::sessions(&db, id)?;
132 Ok(user)
133 })
134 .collect::<Result<Vec<_>>>()?;
135 let mut statement = db.prepare("SELECT id,name FROM roles ORDER BY name")?;
136 let groups = statement
137 .query_map([], |r| {
138 Ok(json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?}))
139 })?
140 .collect::<std::result::Result<Vec<_>, _>>()?;
141 return Ok(Document::new(json!({"users":users,"groups":groups})).response());
170142 }
171 let value = match parts {
172 [] if method == Method::POST => {
173 let mut profile = profile(&body["profile"], true)?;
174 let setup = string(&body["setup"]["kind"]);
175 if setup == "email" && profile["email"].is_null() {
176 return Err(Error::new(400, "Add an email address to send a setup link"));
177 }
178 if setup != "email" && setup != "password" {
179 return Err(Error::new(400, "Choose how this user signs in."));
180 }
181 if setup == "password" {
182 password(&body["setup"]["password"])?;
183 }
184 if !body["groups"].is_array() {
185 return Err(Error::new(400, "Choose groups."));
186 }
143 if parts.is_empty() && method == Method::POST {
144 let mut profile = profile(&body["profile"], true)?;
145 let setup = string(&body["setup"]["kind"]);
146 if setup != "invite" && setup != "password" {
147 return Err(Error::new(400, "Choose an invitation or a password."));
148 }
149 let hash = if setup == "password" {
150 Some(
151 app.auth
152 .hash_password(password(&body["setup"]["password"])?)
153 .await?,
154 )
155 } else {
156 None
157 };
158 if !body["groups"].is_array() {
159 return Err(Error::new(400, "Choose groups."));
160 }
161 let id = uuid::Uuid::new_v4().to_string();
162 profile["enabled"] = json!(true);
163 profile["emailVerified"] = json!(false);
164 profile["requiredActions"] = json!([if hash.is_some() {
165 "UPDATE_PASSWORD"
166 } else {
167 "SETUP"
168 }]);
169 profile["createdTimestamp"] = json!((now() * 1000.0) as i64);
170 profile["attributes"] = json!({});
171 {
172 let mut db = app.auth.db.lock().unwrap();
173 let transaction = db.transaction()?;
174 transaction
175 .execute(
176 "INSERT INTO users(id,profile) VALUES (?,?)",
177 sql![id, profile.to_string()],
178 )
179 .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?;
187180 for group in array(&body["groups"]) {
188 uuid(string(group))?;
181 let group = string(group);
182 uuid(group)?;
183 if transaction.execute(
184 "INSERT OR IGNORE INTO memberships SELECT ?,id FROM roles WHERE id=?",
185 sql![id, group],
186 )? == 0
187 {
188 return Err(Error::new(400, "Choose an available group."));
189 }
189190 }
190 let actions = if setup == "email" {
191 json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"])
192 } else {
193 json!([])
194 };
195 profile["enabled"] = json!(true);
196 profile["emailVerified"] = json!(false);
197 profile["requiredActions"] = actions.clone();
198 let response = call("/users", Method::POST, Some(profile)).await?;
199 let id = response["id"]
200 .as_str()
201 .ok_or_else(|| {
202 Error::new(
203 502,
204 "Keycloak created the user but did not return its ID. Reload the page.",
205 )
206 })?
207 .to_owned();
208 for group in array(&body["groups"]) {
209 change_role(&id, string(group), Method::POST).await?;
191 if let Some(hash) = &hash {
192 auth::set_password(&transaction, &id, hash)?;
210193 }
211 if setup == "email" {
212 call(
213 &format!("/users/{id}/execute-actions-email"),
214 Method::PUT,
215 Some(actions),
216 )
217 .await?;
218 } else {
219 call(&format!("/users/{id}/reset-password"),Method::PUT,Some(json!({"type":"password","value":body["setup"]["password"],"temporary":true}))).await?;
220 }
221 app.cache.invalidate("users");
222 return Ok((StatusCode::CREATED, axum::Json(json!({"id":id}))).into_response());
194 transaction.commit()?;
223195 }
224 [id] if method == Method::PATCH => {
225 let profile = profile(&body, false)?;
226 if profile["enabled"] == false {
227 spare(me, id, None).await?;
196 return Ok((StatusCode::CREATED,axum::Json(json!({"id":id,"url":if setup=="invite" {Some(app.auth.setup_link(&id)?)}else{None}}))).into_response());
197 }
198 let id = parts
199 .first()
200 .ok_or_else(|| Error::new(404, "No user here."))?;
201 uuid(id)?;
202 if *parts == [*id, "setup-link"] && method == Method::POST {
203 return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response());
204 }
205 let hash = if *parts == [*id, "password"] && method == Method::PUT {
206 Some(app.auth.hash_password(password(&body["password"])?).await?)
207 } else {
208 None
209 };
210 let mut db = app.auth.db.lock().unwrap();
211 let transaction = db.transaction()?;
212 let mut user = auth::user(&transaction, id)?;
213 let own = user["username"] == me["name"];
214 let value = match parts {
215 [_] if method == Method::PATCH => {
216 let patch = profile(&body, false)?;
217 if own && patch["enabled"] == false {
218 return Err(Error::new(
219 400,
220 "Sign in as another admin to disable your account.",
221 ));
228222 }
229 call(&format!("/users/{id}"), Method::PUT, Some(profile)).await?;
223 if patch.get("username").is_some() && patch["username"] != user["username"] {
224 return Err(Error::new(
225 400,
226 "Usernames are fixed to preserve service identities.",
227 ));
228 }
229 user.as_object_mut()
230 .unwrap()
231 .extend(patch.as_object().unwrap().clone());
232 auth::save_user(&transaction, id, user)?;
230233 Value::Null
231234 }
232 [id] if method == Method::DELETE => {
233 spare(me, id, None).await?;
234 call(&format!("/users/{id}"), Method::DELETE, None).await?;
235 [_] if method == Method::DELETE => {
236 if own {
237 return Err(Error::new(
238 400,
239 "Sign in as another admin to delete your account.",
240 ));
241 }
242 transaction.execute(
243 "DELETE FROM pending WHERE json_extract(data,'$.user')=?",
244 [id],
245 )?;
246 transaction.execute("DELETE FROM users WHERE id=?", [id])?;
235247 Value::Null
236248 }
237 [id, "groups", group] if method == Method::PUT || method == Method::DELETE => {
238 uuid(group)?;
239 if method == Method::DELETE {
240 let groups = get("/roles").await?;
241 let name = array(&groups)
242 .iter()
243 .find(|g| g["id"] == *group)
244 .map(|g| string(&g["name"]));
245 spare(me, id, name).await?;
249 [_, "groups", group] if method == Method::PUT || method == Method::DELETE => {
250 let name: Option<String> = transaction
251 .query_row("SELECT name FROM roles WHERE id=?", [group], |r| r.get(0))
252 .optional()?;
253 let name = name
254 .ok_or_else(|| Error::new(404, "This group no longer exists. Reload the page."))?;
255 if own && method == Method::DELETE && name == "infra-admin" {
256 return Err(Error::new(
257 400,
258 "Sign in as another admin to remove your admin access.",
259 ));
260 }
261 if method == Method::PUT {
262 transaction.execute(
263 "INSERT OR IGNORE INTO memberships VALUES (?,?)",
264 sql![id, group],
265 )?;
266 } else {
267 transaction.execute(
268 "DELETE FROM memberships WHERE user_id=? AND role_id=?",
269 sql![id, group],
270 )?;
246271 }
247 change_role(
248 id,
249 group,
250 if method == Method::PUT {
251 Method::POST
252 } else {
253 Method::DELETE
254 },
255 )
256 .await?;
257272 Value::Null
258273 }
259 [id, "credentials"] if method == Method::GET => {
260 get(&format!("/users/{id}/credentials")).await?
261 }
262 [id, "logout"] if method == Method::POST => {
263 call(&format!("/users/{id}/logout"), Method::POST, None).await?;
274 [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?,
275 [_, "logout"] if method == Method::POST => {
276 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
264277 Value::Null
265278 }
266 [id, "actions-email"] if method == Method::POST => {
267 let user = found(id).await?;
268 if user["email"].is_null() {
269 return Err(Error::new(400, "Add an email address first"));
270 }
271 if array(&user["requiredActions"]).is_empty() {
272 return Err(Error::new(400, "Pick at least one required action first"));
273 }
274 call(
275 &format!("/users/{id}/execute-actions-email"),
276 Method::PUT,
277 Some(user["requiredActions"].clone()),
278 )
279 .await?;
279 [_, "setup-link"] if method == Method::DELETE => {
280 transaction.execute(
281 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
282 [id],
283 )?;
280284 Value::Null
281285 }
282 [id, "password"] if method == Method::PUT => {
283 let password = password(&body["password"])?;
286 [_, "password"] if method == Method::PUT => {
284287 if !body["temporary"].is_boolean() {
285288 return Err(Error::new(
286289 400,
287290 "Choose whether this password is temporary.",
288291 ));
289292 }
290 call(
291 &format!("/users/{id}/reset-password"),
292 Method::PUT,
293 Some(json!({"type":"password","value":password,"temporary":body["temporary"]})),
294 )
295 .await?;
293 auth::set_password(&transaction, id, hash.as_deref().unwrap())?;
294 user["requiredActions"] = if body["temporary"] == true {
295 json!(["UPDATE_PASSWORD"])
296 } else {
297 json!([])
298 };
299 auth::save_user(&transaction, id, user)?;
300 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
296301 Value::Null
297302 }
298 _ => return Err(Error::new(404, "Not Found")),
303 _ => return Err(Error::new(404, "No account action here.")),
299304 };
300305 if method != Method::GET {
301 app.cache.invalidate("users");
306 transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration','handoff') AND json_extract(data,'$.user')=?",[id])?;
307 if body["enabled"] == false {
308 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
309 transaction.execute(
310 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
311 [id],
312 )?;
313 }
314 }
315 transaction.commit()?;
316 drop(db);
317 if body["enabled"] == false
318 || hash.is_some()
319 || (method == Method::DELETE && !matches!(parts, [_, "setup-link"]))
320 || matches!(parts, [_, "logout"])
321 {
322 revoke_connections(&app, id)?;
302323 }
303324 Ok(if value.is_null() {
304325 StatusCode::NO_CONTENT.into_response()
......@@ -306,54 +327,6 @@ pub async fn route(
306327 Document::new(value).response()
307328 })
308329}
309async fn change_role(id: &str, group: &str, method: Method) -> Result<()> {
310 let roles = get("/roles").await?;
311 let role = array(&roles)
312 .iter()
313 .find(|g| g["id"] == group)
314 .ok_or_else(|| Error::new(404, "That role is no longer available. Reload the page."))?;
315 call(
316 &format!("/users/{id}/role-mappings/realm"),
317 method,
318 Some(json!([role])),
319 )
320 .await?;
321 Ok(())
322}
323pub async fn self_user(app: &App, me: &Value) -> Result<Value> {
324 let name = string(&me["name"]).to_owned();
325 let value = app
326 .cache
327 .coalesce(format!("identity:{name}"), move || async move {
328 let found = get(&format!("/users?username={}&exact=true", encoded(&name))).await?;
329 let mut user = array(&found)
330 .iter()
331 .find(|u| u["username"] == name)
332 .cloned()
333 .ok_or_else(|| {
334 Error::new(
335 404,
336 format!(
337 "Keycloak has no user named {}. Sign out, then sign in again.",
338 name
339 ),
340 )
341 })?;
342 user["groups"] = get(&format!(
343 "/users/{}/role-mappings/realm",
344 encoded(string(&user["id"]))
345 ))
346 .await?;
347 for key in ["email", "firstName", "lastName"] {
348 if user.get(key).is_none() {
349 user[key] = Value::Null;
350 }
351 }
352 Ok(user)
353 })
354 .await?;
355 Ok(value.value.clone())
356}
357330fn image_type(bytes: &[u8]) -> Option<&'static str> {
358331 if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
359332 Some("image/webp")
......@@ -396,130 +369,62 @@ pub async fn account(
396369 me: &Value,
397370) -> Result<Response> {
398371 let method = request.method().clone();
399 let headers = request.headers();
400 let origin = format!(
401 "{}://{}",
402 headers
403 .get("X-Forwarded-Proto")
404 .and_then(|h| h.to_str().ok())
405 .unwrap_or("http"),
406 headers
407 .get("X-Forwarded-Host")
408 .or(headers.get("Host"))
409 .and_then(|h| h.to_str().ok())
410 .unwrap_or("localhost")
411 );
412 let realm = || {
413 std::env::var("STUDIO_KEYCLOAK_URL")
414 .map(|s| format!("{s}/realms/master"))
415 .map_err(|_| {
416 Error::new(
417 501,
418 "Keycloak isn't connected to this home server. Connect it, then retry.",
419 )
420 })
421 };
422 if parts == ["sign-out"] && method == Method::GET {
423 let logout = format!(
424 "{}/protocol/openid-connect/logout?{}",
425 realm()?,
426 params(&[
427 ("client_id", "forward-auth".into()),
428 ("post_logout_redirect_uri", format!("{origin}/"))
429 ])
430 );
431 return Ok((
432 StatusCode::FOUND,
433 [(
434 "location",
435 format!("/snow.oauth2/sign_out?{}", params(&[("rd", logout)])),
436 )],
437 )
438 .into_response());
439 }
440 if let ["actions", action] = parts {
441 if method != Method::GET
442 || (![
443 "webauthn-register-passwordless",
444 "UPDATE_PASSWORD",
445 "UPDATE_EMAIL",
446 ]
447 .contains(action)
448 && !regex::Regex::new(r"^delete_credential:[\w-]+$")
449 .unwrap()
450 .is_match(action))
451 {
452 return Err(Error::new(
453 400,
454 "Keycloak can't start that action from here",
455 ));
456 }
457 return Ok((
458 StatusCode::FOUND,
459 [(
460 "location",
461 format!(
462 "{}/protocol/openid-connect/auth?{}",
463 realm()?,
464 params(&[
465 ("client_id", "forward-auth".into()),
466 ("redirect_uri", format!("{origin}/account")),
467 ("response_type", "code".into()),
468 ("scope", "openid".into()),
469 ("kc_action", action.to_string())
470 ])
471 ),
472 )],
473 )
474 .into_response());
475 }
476372 let mut user = self_user(&app, me).await?;
477373 let id = string(&user["id"]).to_owned();
478374 let value = match parts {
479375 [] if method == Method::GET => {
480 let attributes = user
481 .as_object_mut()
482 .unwrap()
483 .remove("attributes")
484 .unwrap_or(Value::Null);
485 user["picture"] = attributes["picture"][0].clone();
486 user["credentials"] = get(&format!("/users/{id}/credentials")).await?;
487 user["console"] = json!(format!("{}/account", realm()?));
376 user["picture"] = user["attributes"]["picture"][0].clone();
377 user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?;
378 user.as_object_mut().unwrap().remove("attributes");
488379 user
489380 }
490381 [] if method == Method::PATCH => {
491 let body: Value = serde_json::from_slice(
492 &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?,
493 )
494 .map_err(|_| Error::new(400, "Invalid profile."))?;
495 let mut value = serde_json::Map::new();
496 for key in ["firstName", "lastName"] {
497 if let Some(v) = body.get(key) {
498 let v = v
499 .as_str()
500 .ok_or_else(|| Error::new(400, "Enter a name."))?
501 .trim();
502 value.insert(
503 key.into(),
504 if v.is_empty() { Value::Null } else { json!(v) },
505 );
382 let body: Value =
383 serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?;
384 for key in ["firstName", "lastName", "email"] {
385 if body.get(key).is_some() {
386 let mut field = serde_json::Map::new();
387 field.insert(key.to_owned(), body[key].clone());
388 let patch = profile(&Value::Object(field), false)?;
389 user[key] = patch[key].clone();
390 if key == "email" {
391 user["emailVerified"] = json!(false);
392 }
506393 }
507394 }
508 call(
509 &format!("/users/{id}"),
510 Method::PUT,
511 Some(Value::Object(value)),
512 )
513 .await?;
395 user["requiredActions"] = json!(
396 array(&user["requiredActions"])
397 .iter()
398 .filter(|v| **v != "UPDATE_PROFILE")
399 .collect::<Vec<_>>()
400 );
401 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
514402 Value::Null
515403 }
516 ["verify-email"] if method == Method::POST => {
517 call(
518 &format!("/users/{id}/execute-actions-email"),
519 Method::PUT,
520 Some(json!(["VERIFY_EMAIL"])),
521 )
522 .await?;
404 ["credentials", credential] if method == Method::DELETE => {
405 app.auth.recent(request.headers())?;
406 let mut db = app.auth.db.lock().unwrap();
407 let transaction = db.transaction()?;
408 let count: i64 = transaction.query_row(
409 "SELECT count(*) FROM credentials WHERE user_id=?",
410 [&id],
411 |r| r.get(0),
412 )?;
413 if count <= 1 {
414 return Err(Error::new(
415 400,
416 "Add another sign-in method before removing this one.",
417 ));
418 }
419 if transaction.execute(
420 "DELETE FROM credentials WHERE user_id=? AND id=?",
421 sql![id, credential],
422 )? == 0
423 {
424 return Err(Error::new(404, "This sign-in method was already removed."));
425 }
426 transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration') AND json_extract(data,'$.user')=?",[&id])?;
427 transaction.commit()?;
523428 Value::Null
524429 }
525430 ["picture"] if method == Method::PUT => {
......@@ -554,32 +459,22 @@ pub async fn account(
554459 tokio::fs::create_dir_all(app.data.join("pictures")).await?;
555460 tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?;
556461 let picture = format!(
557 "{origin}/api/account/pictures/{id}?v={}",
462 "{}/api/account/pictures/{id}?v={}",
463 app.auth.origin.origin().ascii_serialization(),
558464 (now() * 1000.0) as u64
559465 );
560 call(
561 &format!("/users/{id}"),
562 Method::PUT,
563 Some(json!({"attributes":{"picture":[picture]}})),
564 )
565 .await?;
466 user["attributes"]["picture"] = json!([picture]);
467 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
566468 json!({"picture":picture})
567469 }
568470 ["picture"] if method == Method::DELETE => {
569 call(
570 &format!("/users/{id}"),
571 Method::PUT,
572 Some(json!({"attributes":{"picture":null}})),
573 )
574 .await?;
471 user["attributes"]["picture"] = Value::Null;
472 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
575473 let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await;
576474 Value::Null
577475 }
578 _ => return Err(Error::new(404, "Not Found")),
476 _ => return Err(Error::new(404, "No account action here.")),
579477 };
580 if method != Method::GET {
581 app.cache.invalidate("users");
582 }
583478 Ok(if value.is_null() {
584479 StatusCode::NO_CONTENT.into_response()
585480 } else {
dashboard/web/api.contract.ts+16-17
......@@ -1,4 +1,4 @@
1import type { Connections, Consent } from "./types/mcp.ts";
1import type { Access, Connections, Consent, Resources } from "./types/mcp.ts";
22import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts";
33import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts";
44import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts";
......@@ -55,10 +55,10 @@ type ExplorerRoutes<Prefix extends string> = { [P in keyof Explorer as `${Prefix
5555
5656export type Api = Hono<{}, {
5757 "/mcp": { $get: Endpoint<Connections>; };
58 "/mcp/shale": { $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint<null, {}, 204>; };
58 "/mcp/shale": { $get: Endpoint<{ linked: boolean; resources: Resources }>; $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint<null, {}, 204>; };
5959 "/mcp/consent/:id": {
6060 $get: Endpoint<Consent, { param: { id: string } }>;
61 $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: string[] } | { deny: true } }>;
61 $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: Access } | { deny: true } }>;
6262 };
6363 "/mcp/relay/pair": { $post: Endpoint<Connections["machines"][number], { json: { code: string } }>; };
6464 "/mcp/relay/machines/:id": {
......@@ -66,7 +66,11 @@ export type Api = Hono<{}, {
6666 $delete: Endpoint<null, { param: { id: string } }, 204>;
6767 };
6868 "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; };
69 "/mcp/connections/:id": { $delete: Endpoint<null, { param: { id: string } }, 204>; };
69 "/mcp/connections/:id": {
70 $get: Endpoint<{ resources: Resources; selected: Access; linked: boolean; resourceError: string | null }, { param: { id: string } }>;
71 $post: Endpoint<null, { param: { id: string }; json: { resources: Access } }, 204>;
72 $delete: Endpoint<null, { param: { id: string } }, 204>;
73 };
7074
7175 "/me": {
7276 $get: Endpoint<Me>;
......@@ -210,7 +214,7 @@ export type Api = Hono<{}, {
210214 };
211215 "/users": {
212216 $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>;
213 $post: Endpoint<{ id: string; }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "email"; } | { kind: "password"; password: string; }; }; }, 201>;
217 $post: Endpoint<{ id: string; url: string | null }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "invite"; } | { kind: "password"; password: string; }; }; }, 201>;
214218 };
215219 "/users/:id": {
216220 $patch: Endpoint<null, { param: { id: string; }; } & { json: { username?: string | undefined; email?: string | undefined; firstName?: string | undefined; lastName?: string | undefined; enabled?: boolean | undefined; emailVerified?: boolean | undefined; requiredActions?: string[] | undefined; }; }, 204, "body">;
......@@ -226,8 +230,9 @@ export type Api = Hono<{}, {
226230 "/users/:id/logout": {
227231 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
228232 };
229 "/users/:id/actions-email": {
230 $post: Endpoint<null, { param: { id: string; }; }, 204, "body">;
233 "/users/:id/setup-link": {
234 $post: Endpoint<{url:string}, { param: { id: string; }; }>;
235 $delete: Endpoint<null, { param: { id: string; }; }, 204, "body">;
231236 };
232237 "/users/:id/password": {
233238 $put: Endpoint<null, { param: { id: string; }; } & { json: { password: string; temporary: boolean; }; }, 204, "body">;
......@@ -283,11 +288,8 @@ export type Api = Hono<{}, {
283288 $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">;
284289 };
285290 "/account": {
286 $get: Endpoint<User & {picture: string | null; credentials:Credential[]; console: string | null}>;
287 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; }; }, 204, "body">;
288 };
289 "/account/verify-email": {
290 $post: Endpoint<null, {}, 204, "body">;
291 $get: Endpoint<User & {picture: string | null; credentials:Credential[]}>;
292 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; email?: string | undefined; }; }, 204, "body">;
291293 };
292294 "/account/picture": {
293295 $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;
......@@ -296,10 +298,7 @@ export type Api = Hono<{}, {
296298 "/account/pictures/:id": {
297299 $get: Endpoint<Uint8Array, { param: { id: string; }; }, 200, "body">;
298300 };
299 "/account/actions/:action": {
300 $get: Endpoint<undefined, { param: { action: string; }; }, 302, "redirect">;
301 };
302 "/account/sign-out": {
303 $get: Endpoint<undefined, {}, 302, "redirect">;
301 "/account/credentials/:id": {
302 $delete: Endpoint<null, { param: { id: string; }; }, 204, "body">;
304303 };
305304} & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>;
dashboard/web/auth.ts created+24
......@@ -0,0 +1,24 @@
1export async function authRequest<T>(path: string, body?: object): Promise<T> {
2 const response = await fetch(`/auth/${path}`, body ? {
3 method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
4 } : undefined);
5 if (!response.ok) throw new DetailedError(response.statusText, { statusCode: response.status, detail: { data: await response.text() } });
6 return response.status === 204 ? undefined as T : response.json();
7}
8
9export async function addPasskey(label: string) {
10 const { csrf } = await authRequest<{ csrf: string }>("status");
11 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialCreationOptionsJSON }; token: string }>("passkey/register", { csrf });
12 const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.publicKey) });
13 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey setup was canceled. Try again when you're ready.");
14 await authRequest("passkey/save", { csrf, token, credential: credential.toJSON(), label });
15}
16
17export async function signOut() {
18 await authRequest("sign-out", {});
19 window.location.assign("/sign-in");
20}
21
22export const authReason = (failure: unknown) => failure instanceof DetailedError ? reason(failure) : failure instanceof Error ? failure.message : "Couldn't finish sign-in. Try again.";
23import { DetailedError } from "hono/client";
24import { reason } from "./api.ts";
dashboard/web/components/Sidebar.tsx+2-1
......@@ -19,6 +19,7 @@ import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts";
1919import { queries } from "../api.ts";
2020import snowflake from "../snowflake.svg";
2121import { bytes, cores, plural } from "../format.ts";
22import { signOut } from "../auth.ts";
2223import { account, Avatar, displayName } from "../pages/Account.tsx";
2324import { status } from "../pages/Overview.tsx";
2425import { TABS as STORAGE_TABS } from "../pages/Storage.tsx";
......@@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) {
243244 </button>
244245 <div ref={menu} id="account-menu" popover class="account-menu">
245246 <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A>
246 <a href="/api/account/sign-out" rel="external" class="nav-item"><LogOut class="icon" /><span class="label">sign out</span></a>
247 <button class="nav-item" onClick={signOut}><LogOut class="icon" /><span class="label">sign out</span></button>
247248 <Show when={props.me.viewing || props.me.sections.includes("admin")}>
248249 <form class="view-as" onSubmit={(event) => {
249250 event.preventDefault();
dashboard/web/main.tsx+8
......@@ -20,6 +20,8 @@ import { Deploys } from "./pages/Deploys.tsx";
2020import { Deploy } from "./pages/Deploy.tsx";
2121import { VMs } from "./pages/VMs.tsx";
2222import { MCP } from "./pages/MCP.tsx";
23import { MCPConsent } from "./pages/MCPConsent.tsx";
24import { SignIn } from "./pages/SignIn.tsx";
2325import { Account } from "./pages/Account.tsx";
2426import "./styles.css";
2527
......@@ -30,10 +32,12 @@ const preload = (...list: { preload(): void }[]) => () => list.forEach((query) =
3032
3133function Shell(props: RouteSectionProps) {
3234 const location = useLocation();
35 const consent = () => location.pathname.startsWith("/connect/") || location.pathname === "/mcp" && new URLSearchParams(location.search).has("request");
3336 const section = () => location.pathname.startsWith("/services/") ? "admin"
3437 : PAGES.find((page) => page.href !== "/" && location.pathname.startsWith(page.href))?.section;
3538 return (
3639 <>
40 <Show when={location.pathname !== "/sign-in" && !consent()} fallback={<Show when={consent()} fallback={props.children}><MCPConsent /></Show>}>
3741 <Loaded data={me} what="your account" retry={refetch} skeleton={<div class="shell"><div class="sidebar" /><main class="main" /></div>}>
3842 {(user) => (
3943 <div class="shell">
......@@ -55,6 +59,7 @@ function Shell(props: RouteSectionProps) {
5559 </div>
5660 )}
5761 </Loaded>
62 </Show>
5863 <Tooltips />
5964 <Toasts />
6065 </>
......@@ -63,6 +68,7 @@ function Shell(props: RouteSectionProps) {
6368
6469render(() => (
6570 <Router root={Shell}>
71 <Route path="/sign-in" component={SignIn} />
6672 <Route path="/" component={() => <Overview me={me.latest!} />} preload={() => {
6773 queries.launcher.preload();
6874 if (me.latest?.sections.includes("metrics")) preload(queries.host, queries.storage, queries.services)();
......@@ -81,6 +87,8 @@ render(() => (
8187 <Route path="/deploys/:id/:tab?" component={Deploy} preload={preload(queries.deploys, queries.services)} />
8288 <Route path="/vms" component={VMs} preload={preload(queries.vms)} />
8389 <Route path="/mcp" component={MCP} />
90 <Route path="/mcp/settings/:catalog" component={MCP} />
91 <Route path="/connect/:id" component={MCPConsent} />
8492 <Route path="/account" component={Account} preload={preload(queries.launcher)} />
8593 <Route path="*" component={() => <div class="empty">No page here</div>} />
8694 </Router>
dashboard/web/pages/Account.tsx+65-69
......@@ -1,17 +1,18 @@
1import { useNavigate, useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, onMount, Show } from "solid-js";
1import { useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, Show } from "solid-js";
33import { parseResponse } from "hono/client";
44import type { User } from "../types/users.ts";
5import { api, queries, reason } from "../api.ts";
5import { api, reason } from "../api.ts";
66import { Ago } from "../components/Ago.tsx";
77import { lastGood, Loaded } from "../components/Loaded.tsx";
8import { OpenApp } from "../components/OpenApp.tsx";
8import { showConfirmDialog } from "../components/Dialog.tsx";
9import { addPasskey, authReason, authRequest } from "../auth.ts";
910import { Reveal } from "../components/Reveal.tsx";
1011import { toast } from "../components/Toast.tsx";
1112import "./Account.css";
1213
13/** The signed-in user's Keycloak record, shared with the sidebar corner. */
14export const [account, { refetch: refetchAccount }] = createResource(() => parseResponse(api.account.$get()));
14/** Shared with the sidebar corner. */
15export const [account, { refetch: refetchAccount }] = createResource(() => window.location.pathname !== "/sign-in", () => parseResponse(api.account.$get()));
1516
1617export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) =>
1718 [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
......@@ -25,14 +26,7 @@ export function Avatar(props: { picture: string | null; name: string }) {
2526}
2627
2728const PICTURE_SIZE = 256;
28const FIELDS = ["firstName", "lastName"] as const;
29
30const DONE: Record<string, string> = {
31 "webauthn-register-passwordless": "Added a passkey",
32 UPDATE_PASSWORD: "Changed your password",
33 UPDATE_EMAIL: "Sent a link to confirm your new email",
34 delete_credential: "Removed the passkey",
35};
29const FIELDS = ["firstName", "lastName", "email"] as const;
3630
3731/** Center-crops to a square and encodes WebP, or PNG where the browser can't encode WebP; null if it can't read the file. */
3832async function square(file: File) {
......@@ -48,24 +42,12 @@ async function square(file: File) {
4842}
4943
5044export function Account() {
51 const [params] = useSearchParams<{ kc_action?: string; kc_action_status?: string }>();
52 const navigate = useNavigate();
53 const apps = lastGood(queries.launcher.use()[0]);
54 onMount(() => {
55 const { kc_action: action, kc_action_status: status } = params;
56 if (!status) return;
57 if (status === "success" && action) toast(DONE[action] ?? "Done");
58 if (status === "error") toast("Keycloak couldn't finish that. Try again.");
59 navigate("/account", { replace: true });
60 });
61
45 const [params] = useSearchParams<{ welcome?: string }>();
46 const [adding, setAdding] = createSignal(false);
6247 return (
6348 <div class="page account-page">
6449 <div class="page-head">
6550 <h1>profile</h1>
66 <Show when={!account.error && account.latest?.console}>
67 {(href) => <OpenApp app={apps()?.find((app) => app.id === "keycloak") ?? { id: "keycloak", name: "Keycloak", icon: null }} href={href()} />}
68 </Show>
6951 </div>
7052 <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={
7153 <div class="account-grid">
......@@ -75,6 +57,20 @@ export function Account() {
7557 }>
7658 {(user) => (
7759 <div class="account-grid">
60 <Show when={params.welcome}>
61 <section class="card">
62 <h2 class="card-title">want to add a passkey?</h2>
63 <p class="muted">Sign in with your fingerprint, face, or device PIN. Your password stays available.</p>
64 <button class="button primary" disabled={adding()} aria-busy={adding()} onClick={async () => {
65 setAdding(true);
66 try { await addPasskey("passkey"); await refetchAccount(); window.history.replaceState(null,"","/account"); toast("Added a passkey"); }
67 catch(failure) { toast(authReason(failure)); }
68 finally { setAdding(false); }
69 }}>add a passkey</button>{" "}
70 <a class="button" href="/">maybe later</a>
71 </section>
72 </Show>
73 <Show when={user().requiredActions.length}><section class="card"><p class="muted">Finish your profile and change any temporary password to open Snowglobe and Files.</p></section></Show>
7874 <Profile user={user()} />
7975 <SignIn user={user()} />
8076 </div>
......@@ -88,7 +84,7 @@ type Self = NonNullable<typeof account.latest>;
8884
8985function Profile(props: { user: Self }) {
9086 const [dirty, setDirty] = createSignal(false);
91 const [pending, setPending] = createSignal<"save" | "picture" | "verify">();
87 const [pending, setPending] = createSignal<"save" | "picture">();
9288 const [error, setError] = createSignal("");
9389 const [pictureError, setPictureError] = createSignal("");
9490 const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>;
......@@ -103,7 +99,7 @@ function Profile(props: { user: Self }) {
10399 setError("");
104100 };
105101
106 const busy = async (key: "save" | "picture" | "verify", work: () => Promise<unknown>, fail = setError) => {
102 const busy = async (key: "save" | "picture", work: () => Promise<unknown>, fail = setError) => {
107103 setPending(key);
108104 fail("");
109105 try {
......@@ -141,11 +137,6 @@ function Profile(props: { user: Self }) {
141137 await refetchAccount();
142138 }, setPictureError);
143139
144 const resend = () => busy("verify", async () => {
145 await parseResponse(api.account["verify-email"].$post());
146 toast(`Sent a link to ${props.user.email}`);
147 });
148
149140 return (
150141 <section class="card">
151142 <div class="picture-row">
......@@ -175,16 +166,8 @@ function Profile(props: { user: Self }) {
175166 <label class="label" for="last-name">last name</label>
176167 <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name"
177168 readOnly={pending() === "save"} />
178 <span class="label">email</span>
179 <span class="email">
180 <span class="address">{props.user.email ?? <span class="muted">none</span>}</span>
181 <Show when={props.user.email && !props.user.emailVerified}>
182 <span class="chip warn">unverified</span>
183 <button type="button" class="button small" disabled={pending() === "verify"} aria-busy={pending() === "verify"}
184 onClick={resend}>resend link</button>
185 </Show>
186 <a class="button small" href="/api/account/actions/UPDATE_EMAIL">{props.user.email ? "change" : "add email"}</a>
187 </span>
169 <label class="label" for="profile-email">email</label>
170 <input id="profile-email" ref={inputs.email} class="search" type="email" value={props.user.email ?? ""} autocomplete="email" readOnly={pending() === "save"} />
188171 <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show>
189172 <span />
190173 <Reveal when={dirty()}>
......@@ -206,28 +189,41 @@ function Profile(props: { user: Self }) {
206189function SignIn(props: { user: Self }) {
207190 const password = () => props.user.credentials.find((credential) => credential.type === "password");
208191 const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn"));
209 return (
210 <section class="card">
211 <h2 class="card-title">sign-in</h2>
212 <div class="credentials">
213 <span class="label">password</span>
214 <span>
215 <Show when={password()} fallback={<span class="muted">none</span>}>{(set) => <>set <Ago t={set().createdDate / 1000} /></>}</Show>
216 </span>
217 <a class="button small" href="/api/account/actions/UPDATE_PASSWORD">{password() ? "change" : "set password"}</a>
218 <span class="label">passkeys</span>
219 <span><Show when={!passkeys().length}><span class="muted">none</span></Show></span>
220 <a class="button small" href="/api/account/actions/webauthn-register-passwordless">add passkey</a>
221 <For each={passkeys()}>
222 {(passkey) => (
223 <>
224 <span />
225 <span class="passkey">{passkey.userLabel ?? "unnamed"} <span class="muted"><Ago t={passkey.createdDate / 1000} /></span></span>
226 <a class="button small" href={`/api/account/actions/delete_credential:${passkey.id}`}>remove</a>
227 </>
228 )}
229 </For>
230 </div>
231 </section>
232 );
192 const [busy, setBusy] = createSignal(false);
193 const [error, setError] = createSignal("");
194 const run = async (work: () => Promise<unknown>) => {
195 setBusy(true); setError("");
196 try { await work(); await refetchAccount(); } catch(failure) { setError(authReason(failure)); } finally { setBusy(false); }
197 };
198 const change = () => showConfirmDialog({
199 title: password() ? "Change password" : "Set password", confirmLabel: "save password",
200 body: <>
201 <Show when={password()}><label class="field">current password<input name="current" class="search" type="password" required autocomplete="current-password" /></label></Show>
202 <label class="field">new password<input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" /></label>
203 </>,
204 onConfirm: async (form) => {
205 try {
206 const {csrf} = await authRequest<{csrf:string}>("status");
207 await authRequest("password/change", {csrf, current: String(form.get("current") ?? ""), password: String(form.get("password") ?? "")});
208 await refetchAccount(); toast("Saved your password");
209 } catch(failure) { setError(authReason(failure)); throw failure; }
210 },
211 });
212 return <section class="card">
213 <h2 class="card-title">sign-in</h2>
214 <p class="muted">These sign-in methods work with Snowglobe and Files.</p>
215 <div class="credentials">
216 <span class="label">password</span><span>{password() ? "set" : "none"}</span>
217 <button class="button small" onClick={change}>{password() ? "change" : "set password"}</button>
218 <span class="label">passkeys</span><span>{passkeys().length ? "" : "none"}</span>
219 <button class="button small" disabled={busy()} onClick={() => run(() => addPasskey("passkey"))}>add passkey</button>
220 <For each={passkeys()}>{(key) => <>
221 <span /><span>{key.userLabel ?? "unnamed"} <span class="muted"><Ago t={key.createdDate / 1000} /></span></span>
222 <button class="button small" disabled={busy() || props.user.credentials.length <= 1} onClick={() => run(async () => {
223 await parseResponse(api.account.credentials[":id"].$delete({param:{id:key.id}}));
224 })}>remove</button>
225 </>}</For>
226 </div>
227 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
228 </section>;
233229}
dashboard/web/pages/MCP.css+68-11
......@@ -1,12 +1,69 @@
1.mcp-page h2 { margin-top: 2rem; }
2.mcp-connector, .mcp-consent { padding: 1.5rem; border: 1px solid var(--line); border-radius: 8px; margin: 1rem 0; }
3.mcp-connector h3, .mcp-consent h2 { margin-top: 0; }
4.mcp-resources { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin: 1.5rem 0; }
5.mcp-actions { display: flex; gap: .75rem; }
1.mcp-page { max-width: 1100px; }
2.mcp-page h2 { font-size: 17px; margin: 0 0 12px; color: var(--text); }
3.mcp-page h3 { font-size: 15px; margin: 0; }
4.mcp-page p { color: var(--text-2); }
5.mcp-navigation { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin: 20px 0 24px; }
6.mcp-navigation a { padding: 8px 12px; border-radius: 6px; color: var(--text-2); }
7.mcp-navigation a:hover { background: var(--hover); }
8.mcp-navigation a.active { background: var(--accent-wash); color: var(--accent); }
9.mcp-catalogs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
10.mcp-catalog-card, .mcp-panel, .mcp-endpoint { border: 1px solid var(--line); border-radius: 10px; padding: 20px; background: var(--surface); }
11.mcp-catalog-card:hover { border-color: var(--accent); }
12.mcp-card-heading, .mcp-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
13.mcp-catalog-card p { min-height: 40px; }
14.mcp-card-status { display: grid; gap: 6px; font-size: 12px; color: var(--muted); margin-top: 24px; }
15.mcp-panel, .mcp-endpoint { margin: 20px 0; }
16.mcp-endpoint p { margin: 0 0 16px; }
17.mcp-endpoint .copy { max-width: 100%; }
18.mcp-actions { display: flex; flex-wrap: wrap; gap: 8px; }
19.mcp-access { padding: 0; margin: 20px 0; border: 0; min-width: 0; }
20.mcp-access legend { font-weight: 600; margin-bottom: 12px; }
21.mcp-access-modes { display: grid; gap: 10px; }
22.mcp-access-modes > label { display: flex; align-items: start; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
23.mcp-access-modes > label:has(input:checked) { border-color: var(--accent); background: var(--accent-wash); }
24.mcp-access-modes input { margin: 4px 0 0; accent-color: var(--accent); }
25.mcp-access-modes span span { display: block; font-size: 12px; margin-top: 4px; }
26.mcp-resources { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 16px 0; }
27.mcp-resources .checkbox { align-items: start; overflow-wrap: anywhere; }
28.mcp-resources small { display: block; margin-top: 4px; }
29.mcp-repository-list > div { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding: 10px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
30.mcp-client { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; padding: 18px 0; border-top: 1px solid var(--line); }
31.mcp-client > .mcp-actions { align-self: start; }
32.mcp-client p { margin: 6px 0 0; overflow-wrap: anywhere; }
33.mcp-edit-access { grid-column: 1 / -1; }
634.mcp-page table { width: 100%; text-align: left; border-collapse: collapse; }
7.mcp-page th, .mcp-page td { padding: .75rem; border-bottom: 1px solid var(--line); }
8
9.mcp-page form { margin: 1rem 0; }
10.mcp-page form label { display: flex; align-items: center; gap: .75rem; }
11.mcp-page input { padding: .5rem; }
12.mcp-page form > button { margin-top: .75rem; }
35.mcp-page th, .mcp-page td { padding: 12px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
36.mcp-page form { margin: 16px 0; }
37.mcp-page form > label { display: flex; align-items: center; gap: 12px; }
38.mcp-page input { padding: 8px; }
39.mcp-page form > button { margin-top: 12px; }
40.mcp-help { margin: 24px 0; }
41.mcp-help summary { cursor: pointer; }
42.mcp-help li { margin: 12px 0; }
43.mcp-connector { padding: 20px; border: 1px solid var(--line); border-radius: 8px; margin: 16px 0; }
44.mcp-authorization { min-height: 100%; display: grid; place-items: center; padding: 40px 24px; box-sizing: border-box; }
45.mcp-approval { width: min(100%, 600px); padding: 32px; box-sizing: border-box; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
46.mcp-approval-brand { color: var(--accent); font-size: 13px; font-weight: 600; margin-bottom: 28px; }
47.mcp-approval h1 { font-size: 28px; line-height: 1.2; margin: 0; overflow-wrap: anywhere; }
48.mcp-approval h2 { font-size: 15px; margin: 0 0 12px; }
49.mcp-approval-intro { font-size: 16px; color: var(--text-2); margin: 12px 0 24px; }
50.mcp-request-identity { padding: 16px 0; border-block: 1px solid var(--line); margin: 0; }
51.mcp-request-identity > div { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 16px; margin: 6px 0; }
52.mcp-request-identity dt { color: var(--muted); }
53.mcp-request-identity dd { margin: 0; overflow-wrap: anywhere; }
54.mcp-permissions, .mcp-link-step { padding: 24px 0; border-bottom: 1px solid var(--line); }
55.mcp-permissions p { margin: 8px 0; }
56.mcp-approval-actions { display: flex; justify-content: space-between; gap: 16px; padding-top: 24px; border-top: 1px solid var(--line); margin-top: 24px; }
57.mcp-close { background: none; border: 0; color: var(--muted); cursor: pointer; margin-top: 20px; padding: 0; text-decoration: underline; }
58@media (max-width: 760px) {
59 .mcp-catalogs { grid-template-columns: 1fr; }
60 .mcp-catalog-card p { min-height: 0; }
61 .mcp-card-status { margin-top: 16px; }
62 .mcp-client { grid-template-columns: 1fr; }
63 .mcp-section-heading { flex-wrap: wrap; }
64 .mcp-repository-list > div { grid-template-columns: 1fr; gap: 4px; }
65 .mcp-page form > label { flex-wrap: wrap; }
66 .mcp-page input { max-width: 100%; min-width: 0; }
67 .mcp-authorization { padding: 20px 12px; align-items: start; }
68 .mcp-approval { padding: 24px 20px; }
69}
dashboard/web/pages/MCP.tsx+115-74
......@@ -1,94 +1,106 @@
1import { useLocation } from "@solidjs/router";
1import { A, useParams } from "@solidjs/router";
22import { parseResponse } from "hono/client";
33import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
44import { api, reason } from "../api.ts";
55import { Ago } from "../components/Ago.tsx";
66import { Checkbox } from "../components/Checkbox.tsx";
77import { Copy } from "../components/Copy.tsx";
8import { showConfirmDialog } from "../components/Dialog.tsx";
89import { Loaded } from "../components/Loaded.tsx";
910import { toast } from "../components/Toast.tsx";
11import { MCPAccess } from "./MCPAccess.tsx";
12import type { Access, Catalog } from "../types/mcp.ts";
1013import "./MCP.css";
1114
15const descriptions: Record<Catalog, string> = {
16 shale: "Read and edit issues across your Shale repositories.",
17 agents: "Connect to Codex and Claude Code on your machines.",
18 observability: "Read logs and traces from your services.",
19};
20
1221export function MCP() {
13 const location = useLocation();
14 const request = () => new URLSearchParams(location.search).get("request");
22 const params = useParams<{ catalog?: string }>();
1523 const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get()));
16 const [consent, { refetch: retryConsent }] = createResource(() => request() || false,
17 (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } })));
18 const [picked, setPicked] = createSignal<string[]>([]);
24 const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale",
25 () => parseResponse(api.mcp.shale.$get()));
26 const [editing, setEditing] = createSignal("");
27 const [selection, setSelection] = createSignal<Access>([]);
28 const [connection, { refetch: retryConnection, mutate: clearConnection }] = createResource(() => editing() || false, async (id) => {
29 clearConnection(undefined);
30 const result = await parseResponse(api.mcp.connections[":id"].$get({ param: { id } }));
31 if (editing() === id) setSelection(result.selected === "all" ? "all" : result.selected.filter((id) => result.resources.some((resource) => resource.id === id)));
32 return result;
33 });
1934 const [busy, setBusy] = createSignal(false);
2035 const [code, setCode] = createSignal("");
2136 const [keyName, setKeyName] = createSignal("");
2237 const [keyMachines, setKeyMachines] = createSignal<string[]>([]);
2338 const [control, setControl] = createSignal(false);
2439 const [key, setKey] = createSignal("");
25 createEffect(() => { request(); setPicked([]); setBusy(false); });
26 let events: EventSource | undefined;
40 createEffect(() => { params.catalog; setEditing(""); setKey(""); });
2741 createEffect(() => {
28 if (!overview() || events) return;
29 events = new EventSource("/api/mcp/relay/live");
42 if (params.catalog !== "agents") return;
43 const events = new EventSource("/api/mcp/relay/live");
3044 events.onmessage = (event) => {
3145 const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data);
3246 mutate((previous) => previous && { ...previous, machines });
3347 };
48 onCleanup(() => events.close());
3449 });
35 onCleanup(() => events?.close());
3650 const linkShale = async () => {
3751 setBusy(true);
38 try {
39 const result = await parseResponse(api.mcp.shale.$post({ json: { request: consent()?.scopes.includes("shale:read") ? request() || undefined : undefined } }));
40 window.location.assign(result.redirect);
41 } catch (error) { toast(reason(error)); setBusy(false); }
42 };
43 const answer = async (deny: boolean) => {
44 setBusy(true);
45 try {
46 const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request()! }, json: deny ? { deny: true } : { resources: picked() } }));
47 window.location.assign(result.redirect);
48 } catch (error) { toast(reason(error)); setBusy(false); }
52 try { const result = await parseResponse(api.mcp.shale.$post({ json: {} })); window.location.assign(result.redirect); }
53 catch (error) { toast(reason(error)); setBusy(false); }
4954 };
5055 return <div class="page mcp-page">
51 <header class="page-header"><h1>MCP</h1></header>
52 <Show when={request()}>
53 <Loaded data={consent} what="connection request" retry={retryConsent}>
54 {(details) => <section class="mcp-consent">
55 <h2>Connect {details().client}</h2>
56 <p>{details().scopes.includes("shale:read") ? "Choose repositories this connection can read issues from." : details().scopes.includes("sessions:read") ? "Choose machines this connection can read sessions from." : "Choose services this connection can read logs and traces from."}</p>
57 <Show when={details().scopes.includes("sessions:write")}><p>This connection can send messages, start sessions, and interrupt turns on the selected machines.</p></Show>
58 <Show when={details().scopes.includes("shale:write")}><p>This connection can create issues, comment, and change issue status in the selected repositories.</p></Show>
59 <div class="mcp-resources"><For each={details().resources}>{(resource) =>
60 <Checkbox checked={picked().includes(resource.id)} onChange={(checked) => setPicked(checked ? [...picked(), resource.id] : picked().filter((item) => item !== resource.id))}>{resource.name}</Checkbox>
61 }</For></div>
62 <Show when={!details().linked}><p>Link your Shale account to choose repositories.</p></Show>
63 <Show when={details().linked && !details().resources.length}><p>No resources are available to your account.</p></Show>
64 <Show when={details().scopes.includes("offline_access")}><p class="muted">This connection can refresh access without another sign-in.</p></Show>
65 <div class="mcp-actions"><Show when={details().linked} fallback={<button class="button" disabled={busy()} onClick={linkShale}>Link account</button>}><button class="button" disabled={!picked().length || busy()} onClick={() => answer(false)}>Allow access</button></Show>
66 <button class="button secondary" disabled={busy()} onClick={() => answer(true)}>Decline</button></div>
67 </section>}
68 </Loaded>
69 </Show>
70 <Loaded data={overview} what="MCP connections" retry={refetch}>
71 {(data) => <>
72 <h2>Connectors</h2>
73 <For each={data().catalogs}>{(catalog) => <section class="mcp-connector">
74 <h3>{catalog.name}</h3><p>Add this endpoint to your AI client. Access is granted when you connect.</p>
75 <Copy value={catalog.endpoint} label="connector endpoint" />
76 </section>}</For>
77 <section class="mcp-connector"><h3>Shale account</h3>
78 <Show when={data().shale} fallback={<p>Link your Shale account to grant clients access to its repositories.</p>}>
79 {(shale) => <p>Account linked <Ago t={shale().linkedAt} /></p>}
56 <Loaded data={overview} what="MCP settings" retry={refetch}>
57 {(data) => {
58 const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog);
59 const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id);
60 return <>
61 <header class="page-head"><div><h1>{catalog()?.name || "MCP"}</h1><p class="sub">{catalog() ? descriptions[catalog()!.id] : "Connect your AI clients and manage their access."}</p></div></header>
62 <nav class="mcp-navigation" aria-label="MCP settings">
63 <A href="/mcp" end>Overview</A>
64 <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`}>{catalog.name}</A>}</For>
65 </nav>
66 <Show when={!params.catalog}>
67 <div class="mcp-catalogs"><For each={data().catalogs}>{(catalog) => {
68 const count = () => data().connections.filter((connection) => connection.catalog === catalog.id).length;
69 return <A href={`/mcp/settings/${catalog.id}`} class="mcp-catalog-card">
70 <div class="mcp-card-heading"><h2>{catalog.name}</h2><span aria-hidden="true">↗</span></div>
71 <p>{descriptions[catalog.id]}</p>
72 <div class="mcp-card-status"><span>{catalog.id === "shale" ? data().shale ? "Account linked" : "Account not linked" : catalog.id === "agents" ? `${data().machines.filter((machine) => machine.online).length} machines online` : "Services selected per connection"}</span>
73 <span>{count()} {count() === 1 ? "connection" : "connections"}</span></div>
74 </A>; }}</For></div>
75 <p class="muted">Open a connector to copy its installation URL or change a client’s access.</p>
8076 </Show>
81 <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button>
82 <Show when={data().shale}><button class="button secondary" disabled={busy()} onClick={async () => {
83 setBusy(true);
84 try { await parseResponse(api.mcp.shale.$delete()); await refetch(); }
85 catch (error) { toast(reason(error)); }
86 finally { setBusy(false); }
87 }}>Unlink</button></Show>
88 </section>
77 <Show when={params.catalog && !catalog()}><p class="empty">No connector here.</p></Show>
78 <Show when={catalog()}>{(current) => <>
79 <section class="mcp-endpoint"><div><h2>Connect a client</h2><p>Paste this URL into your AI client’s MCP settings, then approve access.</p></div><Copy value={current().endpoint} label="connector URL" /></section>
80 <Show when={current().id === "shale"}>
81 <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions">
82 <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button>
83 <Show when={data().shale}><button class="button" disabled={busy()} onClick={() => showConfirmDialog({ title: "Unlink Shale?", description: "Every Shale client connection will lose access.", confirmLabel: "Unlink", destructive: true, onConfirm: async () => {
84 await parseResponse(api.mcp.shale.$delete()); await refetch(); await retryShale();
85 } })}>Unlink</button></Show>
86 </div></div>
87 <Loaded data={shale} what="Shale repositories" retry={retryShale}>
88 {(account) => <Show when={account().linked} fallback={<p class="muted">Link your Shale account to connect clients.</p>}>
89 <p><strong>Repository access verified</strong><Show when={data().shale}><span class="muted"> · Linked <Ago t={data().shale!.linkedAt} /></span></Show></p>
90 <div class="mcp-repository-list"><For each={account().resources}>{(resource) => <div><span>{resource.name}</span><span class="muted">{resource.description}</span></div>}</For></div>
91 <Show when={!account().resources.length}><p class="muted">Your account has no repositories yet.</p></Show>
92 </Show>}
93 </Loaded>
94 </section>
95 </Show>
96 <Show when={current().id === "agents"}>
8997 <h2>Local machines</h2>
90 <p>Run the Agent Relay local agent with this dashboard's origin, then enter its pairing code.</p>
91 <Copy value={`npm run agent -- run --server ${window.location.origin}`} label="local agent command" />
98 <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p>
99 <h3>macOS or Linux</h3>
100 <Copy value={`curl -fsSL ${window.location.origin}/agent/install.sh | sh`} label="macOS or Linux install command" />
101 <h3>Windows PowerShell</h3>
102 <Copy value={`irm ${window.location.origin}/agent/install.ps1 | iex`} label="Windows install command" />
103 <p class="muted">Use your usual terminal, without administrator privileges. Codex or Claude Code must already be installed and signed in.</p>
92104 <form class="mcp-actions" onSubmit={async (event) => {
93105 event.preventDefault(); setBusy(true);
94106 try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); }
......@@ -96,7 +108,7 @@ export function MCP() {
96108 finally { setBusy(false); }
97109 }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label>
98110 <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form>
99 <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Pair a local agent to connect it.</p>}>
111 <Show when={data().machines.length} fallback={<p class="muted">No machines linked yet. Run the installer on a machine to link it.</p>}>
100112 <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody>
101113 <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td>
102114 <button class="button small" onClick={async () => {
......@@ -118,19 +130,48 @@ export function MCP() {
118130 <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button>
119131 </form>
120132 </Show>
133 <details class="mcp-help"><summary>How to use linked machines</summary>
134 <ol>
135 <li>Add the Local agents endpoint above to your AI client's MCP connectors. Sign in and choose the machines it can access.</li>
136 <li>Ask the client to list machines, choose a target, and list or read its Codex and Claude Code chats.</li>
137 <li>To start a chat, name the machine, provider, and an existing project folder allowed during installation.</li>
138 </ol>
139 <p>Session control lets a client send messages, start chats, and interrupt turns. Desktop Codex control needs the installer's experimental option.</p>
140 <p>Linked clients can read saved chats on granted machines. Allowed project folders limit where new chats start; existing chats keep their own permissions.</p>
141 <p>For a script or another local tool, create an API key for selected machines. Enable session control only when it needs to write.</p>
142 <p>Rerun the installer to update the agent or change allowed folders. Unlink removes the machine's access immediately.</p>
143 </details>
121144 <Show when={key()}><section class="mcp-connector"><h3>New API key</h3><p>Copy this key now. It won't be shown again.</p><Copy value={key()} label="API key" /><button class="button secondary" onClick={() => setKey("")}>Dismiss</button></section></Show>
122 <h2>Connections</h2>
123 <Show when={data().connections.length} fallback={<p class="muted">No clients connected yet. Add a connector endpoint to your AI client to get started.</p>}>
124 <table><thead><tr><th>Client</th><th>Access</th><th>Added</th><th /></tr></thead><tbody>
125 <For each={data().connections}>{(connection) => <tr><td>{connection.name}</td><td>{connection.resources.join(", ")}<Show when={connection.scopes.includes("sessions:write")}><span class="muted"> · Session control</span></Show><Show when={connection.scopes.includes("shale:write")}><span class="muted"> · Issue editing</span></Show></td><td><Ago t={connection.createdAt} /></td><td>
126 <button class="button small" onClick={async () => {
127 try { await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: connection.id } })); await refetch(); toast("Connection revoked"); }
128 catch (error) { toast(reason(error)); }
129 }}>Revoke</button>
130 </td></tr>}</For>
131 </tbody></table>
132 </Show>
133 </>}
145 </Show>
146 <Show when={current().id === "observability"}><section class="mcp-panel"><h2>Service access</h2><p>Choose services when approving a client. Change its selection below at any time.</p><p class="muted">This connector grants read access to logs and traces.</p></section></Show>
147 <section class="mcp-panel"><h2>Connected clients</h2>
148 <Show when={connections().length} fallback={<p class="muted">No clients connected. Add the connector URL to your AI client to get started.</p>}>
149 <div class="mcp-client-list"><For each={connections()}>{(client) => <article class="mcp-client">
150 <div><h3>{client.name}</h3><p>{client.resources === "all" ? "All Repositories" : client.resources.join(", ")}</p><p class="muted">{client.scopes.includes("sessions:write") ? "Session control" : client.scopes.includes("shale:write") ? "Issue editing" : "Read only"} · Connected <Ago t={client.createdAt} /></p></div>
151 <div class="mcp-actions"><button class="button small" disabled={busy()} onClick={() => setEditing(editing() === client.id ? "" : client.id)}>Edit access</button>
152 <button class="button small" disabled={busy()} onClick={() => showConfirmDialog({ title: "Revoke this connection?", description: `${client.name} will lose access immediately.`, confirmLabel: "Revoke", destructive: true, onConfirm: async () => {
153 await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: client.id } })); if (editing() === client.id) setEditing(""); await refetch(); toast("Connection revoked");
154 } })}>Revoke</button></div>
155 <Show when={editing() === client.id}><div class="mcp-edit-access">
156 <Loaded data={connection} what="connection access" retry={retryConnection}>
157 {(details) => <Show when={details().linked} fallback={<><p>Link your Shale account to change repository access.</p><button class="button" disabled={busy()} onClick={linkShale}>Link account</button></>}>
158 <MCPAccess catalog={current().id} resources={details().resources} value={selection()} onChange={setSelection} disabled={busy()} />
159 <Show when={details().resourceError}><p class="error" role="alert">{details().resourceError}</p></Show>
160 <div class="mcp-actions"><button class="button primary" disabled={busy() || (selection() !== "all" && !selection().length)} onClick={async () => {
161 setBusy(true);
162 try { await parseResponse(api.mcp.connections[":id"].$post({ param: { id: client.id }, json: { resources: selection() } })); setEditing(""); await refetch(); }
163 catch (error) { toast(reason(error)); }
164 finally { setBusy(false); }
165 }}>Save access</button><button class="button" disabled={busy()} onClick={() => setEditing("")}>Cancel</button></div>
166 </Show>}
167 </Loaded>
168 </div></Show>
169 </article>}</For></div>
170 </Show>
171 </section>
172 </>}</Show>
173 </>;
174 }}
134175 </Loaded>
135176 </div>;
136177}
dashboard/web/pages/MCPAccess.tsx created+34
......@@ -0,0 +1,34 @@
1import { For, Show } from "solid-js";
2import { Checkbox } from "../components/Checkbox.tsx";
3import type { Access, Catalog, Resources } from "../types/mcp.ts";
4
5export function MCPAccess(props: {
6 catalog: Catalog;
7 resources: Resources;
8 value: Access;
9 onChange: (value: Access) => void;
10 disabled: boolean;
11}) {
12 return <fieldset class="mcp-access" disabled={props.disabled}>
13 <legend>{props.catalog === "shale" ? "Repositories" : props.catalog === "agents" ? "Machines" : "Services"}</legend>
14 <Show when={props.catalog === "shale"}>
15 <div class="mcp-access-modes">
16 <label><input type="radio" name="repository-access" checked={props.value === "all"} onChange={() => props.onChange("all")} />
17 <span><strong>All Repositories</strong><span class="muted">Includes repositories you can access now and in the future.</span></span>
18 </label>
19 <label><input type="radio" name="repository-access" checked={props.value !== "all"} onChange={() => props.onChange([])} />
20 <span><strong>Selected repositories</strong><span class="muted">Limit this connection to the repositories you choose.</span></span>
21 </label>
22 </div>
23 </Show>
24 <Show when={props.value !== "all"}>
25 <div class="mcp-resources"><For each={props.resources}>{(resource) =>
26 <Checkbox checked={props.value !== "all" && props.value.includes(resource.id)} disabled={props.disabled} onChange={(checked) => {
27 const selected = props.value === "all" ? [] : props.value;
28 props.onChange(checked ? [...selected, resource.id] : selected.filter((id) => id !== resource.id));
29 }}><span>{resource.name}<Show when={resource.description}><small class="muted">{resource.description}</small></Show></span></Checkbox>
30 }</For></div>
31 <Show when={!props.resources.length}><p class="muted">{props.catalog === "agents" ? "No machines linked. Link a machine in MCP settings before connecting a client." : props.catalog === "shale" ? "No repositories available to select." : "No services available to your account."}</p></Show>
32 </Show>
33 </fieldset>;
34}
dashboard/web/pages/MCPConsent.tsx created+84
......@@ -0,0 +1,84 @@
1import { useBeforeLeave, useLocation, useParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createEffect, createResource, createSignal, on, onCleanup, onMount, Show } from "solid-js";
4import { api, reason } from "../api.ts";
5import { showConfirmDialog } from "../components/Dialog.tsx";
6import { MCPAccess } from "./MCPAccess.tsx";
7import type { Access } from "../types/mcp.ts";
8import "./MCP.css";
9
10export function MCPConsent() {
11 const params = useParams<{ id: string }>();
12 const location = useLocation();
13 const request = () => params.id || new URLSearchParams(location.search).get("request") || "";
14 const [consent, { refetch }] = createResource(request,
15 (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } })));
16 const details = () => consent.state === "ready" ? consent.latest : undefined;
17 const [selection, setSelection] = createSignal<Access>();
18 const access = () => selection() ?? (details()?.catalog === "shale" ? "all" : []);
19 const [busy, setBusy] = createSignal(false);
20 const [error, setError] = createSignal("");
21 let leaving = false;
22 createEffect(on(request, () => { setSelection(undefined); setError(""); }));
23 const redirect = (target: string) => { leaving = true; window.location.assign(target); };
24 const answer = async (deny: boolean) => {
25 setBusy(true); setError("");
26 try {
27 const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request() }, json: deny ? { deny: true } : { resources: access() } }));
28 redirect(result.redirect);
29 } catch (error) { setError(reason(error)); setBusy(false); throw error; }
30 };
31 useBeforeLeave((event) => {
32 if (leaving) return;
33 event.preventDefault();
34 if (busy()) return;
35 showConfirmDialog({ title: "Decline this connection?", description: "The client will receive no access.", confirmLabel: "Decline", onConfirm: () => answer(true) });
36 });
37 onMount(() => {
38 const guard = (event: BeforeUnloadEvent) => { if (!leaving) event.preventDefault(); };
39 window.addEventListener("beforeunload", guard);
40 onCleanup(() => window.removeEventListener("beforeunload", guard));
41 });
42 const linkShale = async () => {
43 setBusy(true); setError("");
44 try {
45 const result = await parseResponse(api.mcp.shale.$post({ json: { request: request() } }));
46 redirect(result.redirect);
47 } catch (error) { setError(reason(error)); setBusy(false); }
48 };
49 return <main class="mcp-authorization">
50 <section class="mcp-approval" aria-labelledby="connection-title">
51 <div class="mcp-approval-brand">Snowglobe <span class="muted">· MCP connection</span></div>
52 <Show when={details()} fallback={<>
53 <h1 id="connection-title">{consent.state === "errored" ? "Connection unavailable" : "Loading connection…"}</h1>
54 <Show when={consent.state === "errored"} fallback={<div class="skeleton" style={{ height: "120px" }} aria-label="Loading connection" />}>
55 <p class="error" role="alert">{reason(consent.error)}</p>
56 <button class="button" onClick={() => refetch()}>Retry</button>
57 </Show>
58 </>}>
59 {(data) => <>
60 <h1 id="connection-title">Connect {data().client}</h1>
61 <p class="mcp-approval-intro">{data().catalog === "shale" ? "Grant access to Shale issues." : data().catalog === "agents" ? "Grant access to your local agents." : "Grant access to logs and traces."}</p>
62 <dl class="mcp-request-identity"><div><dt>Signed in as</dt><dd>{data().account}</dd></div><div><dt>Return to</dt><dd>{data().redirectHost}</dd></div></dl>
63 <div class="mcp-permissions"><h2>Requested access</h2>
64 <p>{data().catalog === "shale" ? "Read issues and comments" : data().catalog === "agents" ? "Read saved chats" : "Read logs and traces"}</p>
65 <Show when={data().scopes.includes("shale:write")}><p>Create issues, comment, and edit issue titles and status</p></Show>
66 <Show when={data().scopes.includes("sessions:write")}><p>Send messages, start chats, and interrupt turns</p></Show>
67 <Show when={data().scopes.includes("offline_access")}><p>Stay connected without signing in again</p></Show>
68 </div>
69 <Show when={data().linked} fallback={<div class="mcp-link-step"><h2>Link your Shale account</h2><p>Sign in to Shale, then return here to approve access.</p><button class="button primary" disabled={busy()} onClick={linkShale}>Link account</button></div>}>
70 <MCPAccess catalog={data().catalog} resources={data().resources} value={access()} onChange={setSelection} disabled={busy()} />
71 <Show when={data().resourceError}><p class="error" role="alert">{data().resourceError} <button class="button small" disabled={busy()} onClick={() => refetch()}>Retry</button></p></Show>
72 <p class="muted">You can change this connection’s access later in MCP settings.</p>
73 </Show>
74 </>}
75 </Show>
76 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
77 <div class="mcp-approval-actions">
78 <button class="button" disabled={busy()} onClick={() => answer(true).catch(() => {})}>Decline</button>
79 <Show when={details()?.linked}><button class="button primary" disabled={busy() || (access() !== "all" && !access().length)} onClick={() => answer(false).catch(() => {})}>Allow access</button></Show>
80 </div>
81 <Show when={consent.state === "errored"}><button class="mcp-close" onClick={() => redirect("/mcp")}>Close request</button></Show>
82 </section>
83 </main>;
84}
dashboard/web/pages/SignIn.css created+10
......@@ -0,0 +1,10 @@
1.sign-in-page { min-height: 100dvh; display: grid; align-content: center; justify-items: center; gap: 24px; padding: 24px; }
2.sign-in-brand { font-size: 24px; font-weight: 650; letter-spacing: -.5px; }
3.sign-in-card { width: min(100%, 380px); padding: 28px; }
4.sign-in-card h1 { margin: 0 0 24px; font-size: 22px; }
5.sign-in-card form, .sign-in-card label { display: grid; gap: 8px; }
6.sign-in-card form { gap: 18px; }
7.sign-in-card p { margin: 0; font-size: 13px; line-height: 1.5; }
8.sign-in-card label { color: var(--text-2); font-size: 13px; }
9.sign-in-card input { width: 100%; height: 38px; }
10.sign-in-card button { min-height: 38px; }
dashboard/web/pages/SignIn.tsx created+58
......@@ -0,0 +1,58 @@
1import { createResource, createSignal, Show } from "solid-js";
2import { authReason, authRequest } from "../auth.ts";
3import "./SignIn.css";
4
5export function SignIn() {
6 const params = new URLSearchParams(window.location.search);
7 const setup = params.get("setup");
8 const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string }>(`status${setup ? `?setup=${encodeURIComponent(setup)}` : ""}`));
9 const [username, setUsername] = createSignal("");
10 const [password, setPassword] = createSignal("");
11 const [email, setEmail] = createSignal("");
12 const [busy, setBusy] = createSignal(false);
13 const [error, setError] = createSignal("");
14 const complete = async (passkey = false) => {
15 if (!status() || busy()) return;
16 setBusy(true); setError("");
17 try {
18 const body = { csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,
19 flow: params.get("flow") ?? "", next: params.get("next") ?? "/" };
20 let result: { next: string };
21 if (passkey) {
22 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", body);
23 const credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey) });
24 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
25 result = await authRequest("passkey/finish", { csrf: body.csrf, token, credential: credential.toJSON() });
26 } else result = await authRequest(setup ? "setup" : "password", body);
27 window.location.assign(result.next);
28 } catch (failure) {
29 setError(authReason(failure));
30 } finally { setBusy(false); }
31 };
32 return (
33 <main class="sign-in-page">
34 <div class="sign-in-brand">snow globe</div>
35 <section class="card sign-in-card">
36 <h1>{setup ? "welcome" : "sign in"}</h1>
37 <Show when={!status.error} fallback={<><p class="error" role="alert">{authReason(status.error)}</p><button class="button" onClick={() => refetch()}>try again</button></>}>
38 <form onSubmit={(event) => { event.preventDefault(); void complete(); }}>
39 <Show when={setup} fallback={
40 <label>username<input class="search" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus /></label>
41 }>
42 <p>Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>
43 <label>email<input class="search" type="email" required autocomplete="email" value={email()} onInput={(event) => setEmail(event.currentTarget.value)} /></label>
44 </Show>
45 <label>{setup ? "choose a password" : "password"}<input class="search" type="password" required minLength={setup ? 8 : undefined} maxLength={1024} autocomplete={setup ? "new-password" : "current-password"} value={password()} onInput={(event) => setPassword(event.currentTarget.value)} /></label>
46 <Show when={setup}><p class="muted">Use at least 8 characters. You can add a passkey next.</p></Show>
47 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
48 <button class="button primary" disabled={busy() || !status()} aria-busy={busy()}>{setup ? "create account" : "sign in"}</button>
49 <Show when={!setup}>
50 <button class="button" type="button" disabled={busy() || !status() || !username().trim()} onClick={() => complete(true)}>use a passkey</button>
51 <p class="muted">Need access or a password reset? Ask Clover for an invitation link.</p>
52 </Show>
53 </form>
54 </Show>
55 </section>
56 </main>
57 );
58}
dashboard/web/pages/Users.tsx+29-71
......@@ -15,7 +15,6 @@ import { AppIcon } from "../components/AppIcon.tsx";
1515import { Copy } from "../components/Copy.tsx";
1616import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
1717import { ListPage } from "../components/ListPage.tsx";
18import { OpenApp } from "../components/OpenApp.tsx";
1918import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
2019import { Reveal } from "../components/Reveal.tsx";
2120import { PAGES } from "../components/Sidebar.tsx";
......@@ -24,13 +23,7 @@ import { toast } from "../components/Toast.tsx";
2423import { ago, count, date, datetime, plural } from "../format.ts";
2524import "./Users.css";
2625
27const STEPS: [string, string][] = [
28 ["VERIFY_EMAIL", "verify email"],
29 ["UPDATE_PASSWORD", "new password"],
30 ["UPDATE_PROFILE", "check profile"],
31 ["CONFIGURE_TOTP", "add authenticator"],
32 ["webauthn-register-passwordless", "add passkey"],
33];
26const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]];
3427
3528const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const;
3629
......@@ -50,7 +43,7 @@ const inState = (user: User, state: keyof typeof STATES) =>
5043
5144/** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */
5245function reach(groups: string[], services: ServiceSummary[]) {
53 const apps = services.filter((app) => app.url);
46 const apps = services.filter((app) => app.id === "copyparty" && app.url);
5447 const open = {
5548 apps: apps.filter((app) => canOpen(groups, app.access)),
5649 pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)),
......@@ -79,9 +72,9 @@ function groupTip(group: string, d: Data) {
7972 return `${plural(members, "member")} · opens ${opens}`;
8073}
8174
82/** The app behind a Keycloak `clientId`, which is its service id. */
75
8376const appName = (clientId: string, services: ServiceSummary[]) =>
84 services.find((service) => service.id === clientId)?.name ?? clientId;
77 clientId === "dashboard" ? "Snowglobe" : clientId === "file" ? "Files" : services.find((service) => service.id === clientId)?.name ?? clientId;
8578
8679/** "active 3h ago in Jellyfin, Shale", from their open sessions. */
8780function seen(user: User, services: ServiceSummary[]) {
......@@ -98,15 +91,6 @@ function network(ip: string) {
9891 if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network";
9992}
10093
101/** Keycloak's admin console at `path` inside the realm. */
102function KeycloakLink(props: { services: ServiceSummary[] | undefined; path: string }) {
103 return (
104 <Show when={props.services?.find((service) => service.id === "keycloak" && service.url)}>
105 {(keycloak) => <OpenApp app={keycloak()} href={`${keycloak().url}/admin/master/console/#/master/${props.path}`} />}
106 </Show>
107 );
108}
109
11094/** Where the list was scrolled when a user page opened, so going back lands in the same place. */
11195let listScroll = 0;
11296/** Whether the open user page was reached from the list, so "back" can return to it with its filters. */
......@@ -204,13 +188,13 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;
204188 <div class="field">
205189 first sign-in
206190 <TabBar label="First sign-in">
207 <button type="button" aria-pressed={invite()} onClick={() => setInvite(true)}>email an invite</button>
191 <button type="button" aria-pressed={invite()} onClick={() => setInvite(true)}>create an invitation link</button>
208192 <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button>
209193 </TabBar>
210194 </div>
211195 <Show when={!invite()}>
212196 <label class="field">temporary password
213 <input name="password" class="search" required minLength={8} autocomplete="off" spellcheck={false} />
197 <input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" />
214198 <span class="hint">They pick their own at first sign-in</span>
215199 </label>
216200 </Show>
......@@ -219,16 +203,17 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;
219203 },
220204 onConfirm: async (form) => {
221205 const text = (name: string) => String(form.get(name) ?? "");
222 const { id } = await parseResponse(api.users.$post({
206 const { id, url } = await parseResponse(api.users.$post({
223207 json: {
224208 profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") },
225209 groups: form.getAll("groups").map(String),
226 setup: form.has("password") ? { kind: "password", password: text("password") } : { kind: "email" },
210 setup: form.has("password") ? { kind: "password", password: text("password") } : { kind: "invite" },
227211 },
228212 }));
229213 await props.refetch();
230214 const user = ready()?.users.find((user) => user.id === id);
231215 if (user) open(user);
216 if (url) showConfirmDialog({ title: "Invitation link", description: "Works once and expires in 24 hours. Send it to this person.", body: <Copy value={url} />, confirmLabel: "done", onConfirm: async () => {} });
232217 },
233218 });
234219
......@@ -236,7 +221,6 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>;
236221 <ListPage id="users" flush head={
237222 <div class="page-head">
238223 <h1>users</h1>
239 <KeycloakLink services={ready()?.services} path="users" />
240224 <span class="spacer" />
241225 <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}>
242226 <UserPlus size={14} />new user
......@@ -418,7 +402,6 @@ function Person(props: { name: string; data: Resource<Data>; refetch: () => unkn
418402}
419403
420404function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
421 const navigate = useNavigate();
422405 const back = useBack();
423406 const param = () => ({ id: props.user.id });
424407 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);
......@@ -450,23 +433,22 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
450433 await patch({ enabled });
451434 if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) });
452435 });
453 const toggleStep = (step: string) => run(step, () => patch({
454 requiredActions: props.user.requiredActions.includes(step)
455 ? props.user.requiredActions.filter((a) => a !== step)
456 : [...props.user.requiredActions, step],
457 }));
458 const emailSteps = () => run("email", async () => {
459 await parseResponse(api.users[":id"]["actions-email"].$post({ param: param() }));
460 toast(`Emailed ${props.user.email} a link`);
436 const [setupLink, setSetupLink] = createSignal("");
437 const createLink = () => run("link", async () => {
438 const { url } = await parseResponse(api.users[":id"]["setup-link"].$post({param:param()}));
439 setSetupLink(url);
440 });
441 const revokeLink = () => run("link", async () => {
442 await parseResponse(api.users[":id"]["setup-link"].$delete({param:param()}));
443 setSetupLink(""); toast("Revoked the setup link");
461444 });
462445
463 const setPassword = () => showTextDialog({
446 const setPassword = () => showConfirmDialog({
464447 title: `Set ${props.user.username}'s password`,
465 label: "new password, at least 8 characters",
466 body: <Checkbox name="temporary" checked>ask for a new one at next sign-in</Checkbox>,
448 body: <><label class="field">new password, at least 8 characters<input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" /></label><Checkbox name="temporary" checked>ask for a new one at next sign-in</Checkbox></>,
467449 confirmLabel: "set password",
468 validateInput: (value) => value.length >= 8,
469 onConfirm: async (password, form) => {
450 onConfirm: async (form) => {
451 const password = String(form.get("password") ?? "");
470452 await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } }));
471453 await Promise.all([props.refetch(), credentialActions.refetch()]);
472454 },
......@@ -484,7 +466,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
484466 const name = props.user.username;
485467 showTextDialog({
486468 title: `Delete ${name}?`,
487 description: `${name} is signed out and loses access to everything. This can't be undone.`,
469 description: `${name} is signed out and loses access to Snowglobe and Files. This can't be undone.`,
488470 label: `type ${name} to confirm`,
489471 validateInput: (value) => value === name,
490472 confirmLabel: "delete user",
......@@ -499,7 +481,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
499481 };
500482
501483 const access = () => reach(props.user.enabled ? names(props.user) : [], props.data.services);
502 /** Last use of each app, by the Keycloak `clientId` its sessions went through. */
484
503485 const used = () => {
504486 const last = new Map<string, number>();
505487 for (const session of props.user.sessions) {
......@@ -517,7 +499,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
517499 <h1>{props.user.username}</h1>
518500 <span class="sub">{fullName(props.user)}</span>
519501 <StateTag user={props.user} />
520 <KeycloakLink services={props.data.services} path={`users/${props.user.id}/settings`} />
521502 <span class="spacer" />
522503 <button class="button danger" onClick={remove}>delete user</button>
523504 </div>
......@@ -556,7 +537,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
556537 {(list) => {
557538 const password = () => list().find((c) => c.type === "password");
558539 const passkeys = () => list().filter((c) => c.type.startsWith("webauthn"));
559 const otp = () => list().find((c) => c.type === "otp");
560540 return (
561541 <dl class="kv">
562542 <dt>password</dt>
......@@ -567,33 +547,15 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
567547 {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>}
568548 </For>
569549 </dd>
570 <dt>authenticator</dt>
571 <dd>{otp() ? `added ${date(otp()!.createdDate / 1000)}` : "none"}</dd>
572550 </dl>
573551 );
574552 }}
575553 </Loaded>
576 <h2 class="card-title opens">
577 next sign-in
578 <span class="spacer" />
579 <button class="button small" disabled={!props.user.email || !props.user.requiredActions.length || busy() === "email"}
580 aria-busy={busy() === "email"}
581 data-tip={!props.user.email ? "Add an email address first" : !props.user.requiredActions.length
582 ? "Pick a step first" : `Send ${props.user.email} a link to do these steps now`}
583 onClick={emailSteps}>
584 send email
585 </button>
586 </h2>
587 <div class="toggles" role="group" aria-label="Steps at next sign-in">
588 <For each={STEPS}>
589 {([step, label]) => (
590 <button class="chip toggle" aria-pressed={props.user.requiredActions.includes(step)} disabled={busy() === step}
591 onClick={() => toggleStep(step)}>
592 {label}
593 </button>
594 )}
595 </For>
596 </div>
554 <h2 class="card-title opens">setup link</h2>
555 <p class="muted">One use, valid for 24 hours. A new link replaces the previous one.</p>
556 <button class="button small" disabled={!props.user.enabled || busy() === "link"} onClick={createLink}>create setup link</button>{" "}
557 <button class="button small" disabled={busy() === "link"} onClick={revokeLink}>revoke link</button>
558 <Show when={setupLink()}><p style={{"overflow-wrap":"anywhere"}}><Copy value={setupLink()} /></p></Show>
597559 </section>
598560 </div>
599561
......@@ -601,11 +563,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
601563 <section class="card">
602564 <h2 class="card-title">profile</h2>
603565 <div class="fields">
604 <Field label="username" value={props.user.username} onSave={async (username) => {
605 await parseResponse(api.users[":id"].$patch({ param: param(), json: { username } }));
606 await props.refetch();
607 navigate(`/users/${username.trim().toLowerCase()}`, { replace: true });
608 }} />
566 <span class="label">username</span><span>{props.user.username}</span>
609567 <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} />
610568 <span />
611569 <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"}
dashboard/web/types/mcp.ts+11-3
......@@ -1,12 +1,20 @@
1export type Catalog = "shale" | "agents" | "observability";
2export type Access = "all" | string[];
3export type Resources = { id: string; name: string; description?: string }[];
4
15export interface Connections {
2 catalogs: { name: string; endpoint: string }[];
3 connections: { id: string; name: string; resources: string[]; scopes: string[]; createdAt: number }[];
6 catalogs: { id: Catalog; name: string; endpoint: string }[];
7 connections: { id: string; name: string; catalog: Catalog; resources: Access; scopes: string[]; createdAt: number }[];
48 machines: { id: string; name: string; platform: string; online: boolean }[];
59 shale: { linkedAt: number } | null;
610}
711export interface Consent {
812 linked: boolean;
913 client: string;
14 catalog: Catalog;
15 account: string;
16 redirectHost: string;
1017 scopes: string[];
11 resources: { id: string; name: string }[];
18 resources: Resources;
19 resourceError: string | null;
1220}
dashboard/web/types/users.ts-3
......@@ -1,4 +1,3 @@
1/** Field names and millisecond timestamps follow Keycloak's admin representations. */
21export interface User {
32 id: string;
43 username: string;
......@@ -10,7 +9,6 @@ export interface User {
109 createdTimestamp: number;
1110 requiredActions: string[];
1211 groups: Group[];
13 /** Keycloak replaces the whole map on update, so send it merged. */
1412 attributes?: Record<string, string[]>;
1513}
1614
......@@ -31,6 +29,5 @@ export interface Session {
3129 ipAddress: string;
3230 start: number;
3331 lastAccess: number;
34 /** Client UUID to `clientId`. */
3532 clients: Record<string, string>;
3633}
nixos/configuration.nix+3
......@@ -158,6 +158,9 @@ in
158158 STUDIO_INDEX_DIR = "/data/index";
159159 STUDIO_INTERNAL_URL = "https://dashboard.internal.${config.environment.variables.STUDIO_DOMAIN}:${toString internalPort}";
160160 STUDIO_FILES_URL = "https://file.${config.environment.variables.STUDIO_DOMAIN}";
161 STUDIO_AUTH_REQUIRED = "1";
162 STUDIO_AUTH_RP_ID = "auth.${config.environment.variables.STUDIO_DOMAIN}";
163 STUDIO_FILE_ORIGIN = "https://file.${config.environment.variables.STUDIO_DOMAIN}";
161164 STUDIO_PUBLIC_ORIGIN = "https://snowglobe.${config.environment.variables.STUDIO_DOMAIN}";
162165 STUDIO_KEYCLOAK_URL = "https://auth.${config.environment.variables.STUDIO_DOMAIN}";
163166 STUDIO_JELLYFIN_URL = "https://jelly.${config.environment.variables.STUDIO_DOMAIN}";
nixos/dashboard.nix+4-2
......@@ -1,4 +1,4 @@
1{ stdenv, lib, rustPlatform, runCommand, nodejs_24, pnpm_10, fetchPnpmDeps, pnpmConfigHook, sqlite, pkg-config, dockerTools, coreutils, callPackage, python3, yt-dlp, ffmpeg }:
1{ stdenv, lib, rustPlatform, runCommand, nodejs_24, pnpm_10, fetchPnpmDeps, pnpmConfigHook, sqlite, openssl, pkg-config, dockerTools, coreutils, callPackage, python3, yt-dlp, ffmpeg }:
22let
33 nativePkl = callPackage ./pkl.nix { };
44 youtubePython = python3.withPackages (packages: [ packages.pyyaml ]);
......@@ -38,11 +38,12 @@ let
3838 root = ../dashboard;
3939 fileset = lib.fileset.unions [
4040 ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock
41 ../dashboard/agent/install.sh ../dashboard/agent/install.ps1
4142 ];
4243 };
4344 cargoLock.lockFile = ../dashboard/Cargo.lock;
4445 nativeBuildInputs = [ pkg-config ];
45 buildInputs = [ sqlite ];
46 buildInputs = [ sqlite openssl ];
4647 LIBSQLITE3_SYS_USE_PKG_CONFIG = "1";
4748 };
4849 dashboard = runCommand "home-dashboard-0.1.0" {
......@@ -67,5 +68,6 @@ let
6768 ln -s ${server}/bin/home-dashboard $out/bin/home-dashboard
6869 ln -s ${web} $out/lib/home-dashboard/dist
6970 ln -s ${../dashboard/server} $out/lib/home-dashboard/server
71 ln -s ${../dashboard/agent} $out/lib/home-dashboard/agent
7072'';
7173in dashboard
readme.md+109-1
......@@ -123,8 +123,24 @@ root, private network, resource limits, and explicit data mounts. The small
123123performs bounded ZFS, VM, deployment, host-sampling, and identity operations.
124124Host control sockets and management credentials stay outside the container.
125125
126Snowglobe and Copyparty use the Rust dashboard's accounts and host-only sessions.
127Account state lives in `/var/lib/studio/dashboard/accounts.sqlite`. Deployment
128backups include consistent SQLite copies and profile pictures; `data-restore`
129accepts `dashboard` and preserves a safety copy before restoring. Invitations reserve a username and groups,
130expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment.
131Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related
132origin metadata for Snowglobe. Keycloak remains available for other services.
133
134`tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json`
135exports account IDs, groups, password hashes and public passkey credentials without
136changing the source realm. Keep the export private. The dashboard imports
137`accounts-import.json` from its data directory at startup and removes it after
138success; importing the same export again is safe, while a different export is
139refused once accounts exist. `home-dashboard --import-accounts /private/path/accounts.json`
140supports an offline rehearsal with a separate `STUDIO_DATA_DIR`.
141
126142The MCP tab manages separate observability, agent, and Shale catalogs through
127the existing Keycloak realm. Each connection has explicit service, machine, or
143the native dashboard account. Each connection has explicit service, machine, or
128144repository grants; Shale credentials belong to the signed-in user. Agent Relay's
129145existing outbound client protocol connects to the Rust server.
130146
......@@ -134,3 +150,95 @@ rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP
134150connectors with disposable fixtures. `--relay-agent-dir` includes the existing
135151Agent Relay client interoperability check; `--browser-ready-file` temporarily
136152routes the public dashboard to the fixture for browser and SSO load checks.
153
154## local agents
155
156On each Mac or Linux machine, run this from your usual terminal:
157
158```sh
159curl -fsSL https://snowglobe.paperclover.net/agent/install.sh | sh
160```
161
162On Windows, use a PowerShell window without administrator privileges:
163
164```powershell
165irm https://snowglobe.paperclover.net/agent/install.ps1 | iex
166```
167
168The installer downloads a private Node runtime, verifies its SHA-256 checksum,
169and installs the agent without npm or a repository checkout. On NixOS, it
170installs the runtime through Nix into the agent folder. Prompts ask for a
171machine name, existing project folders where new chats may start, and optional
172experimental Codex desktop control on macOS or Linux. No folders or desktop
173control are enabled on a fresh install unless selected. Codex or Claude Code
174must already be installed and signed in as your login user.
175
176While the installer waits, open **MCP → Settings → Local agents**, enter the printed
177pairing code, and click **Link machine**. Finish installation in the terminal.
178The machine appears **Online** when its background agent connects. Pairing
179belongs to the signed-in dashboard account; each machine connects outward and
180needs no incoming firewall port. Startup uses a systemd user service on Linux,
181a LaunchAgent on macOS, and a current-user scheduled task at logon on Windows.
182Linux needs an active systemd user session. The agent starts immediately after
183installation and again at login.
184
185Copy **Local agents**' endpoint, `https://snowglobe.paperclover.net/mcp/agents`,
186into the AI client's MCP connector settings using OAuth. Sign in to the
187dashboard and select the machines the client may access. The consent screen
188shows whether the connection requests session control. A granted machine
189exposes saved Codex and Claude Code chats; project folders constrain **new**
190chats, not saved-chat reads or the permissions of existing chats.
191
192Example requests to the connected AI client:
193
194```text
195List my machines, target "Work PC", and show its recent Codex chats.
196Read the latest chat in that list.
197Start a Codex chat on "Work PC" in C:\Users\Clover\dev\site:
198check the build and fix the failing tests.
199Read that chat again to check the result.
200```
201
202Read access supports listing machines and chats and reading transcripts.
203Session control adds starting chats, sending messages, and interrupting turns.
204Writes always select one machine. Agent-owned Codex and Claude Code chats
205support these operations; existing Codex desktop control is experimental and
206requires the installer option. Existing Claude Code chats accept messages only
207when their local inbox supports delivery. Windows installs support saved-chat
208reads and agent-owned CLI chats; this installer does not enable existing
209desktop chat control there. A submitted message acknowledges delivery; read
210the chat again for its result. Commands needing local approval are refused.
211
212For an external script, create an **API key** in the MCP tab, select its
213machines, and enable **Allow session control** only if required. Use the key
214as a bearer token with `/api/v1/machines` and
215`/api/v1/machines/{id}/commands`. Keep it in the script's secret store. Unlinking
216a machine disconnects it and revokes its machine credential; revoking a client
217connection removes only that client's access.
218
219Rerun the install command to update the agent or change project folders. It
220keeps the machine identity and pairing. Leaving the first folder answer blank
221keeps existing folders; entering `-` clears them. A reinstall needs the existing
222pairing to remain active. Unlink first, then remove the saved `agent.json` if
223you want a new pairing or a different dashboard account.
224
225The installed `agent-relay` command accepts `status`, `start`, `stop`, and
226`uninstall`. Use its full path:
227
228| Platform | Command | Logs |
229| --- | --- | --- |
230| Linux | `~/.local/share/agent-relay/agent-relay status` | `journalctl --user -u agent-relay -f` |
231| macOS | `"$HOME/Library/Application Support/AgentRelay/agent-relay" status` | `~/Library/Application Support/AgentRelay/agent.log` |
232| Windows | `& "$env:LOCALAPPDATA\AgentRelay\agent-relay.cmd" status` | `%LOCALAPPDATA%\AgentRelay\agent.log` |
233
234Linux honors `XDG_DATA_HOME` and `XDG_CONFIG_HOME`. Uninstall removes startup
235registration and stops the agent, preserving pairing and session files. Pairing
236is in `~/.config/agent-relay/agent.json` on macOS/Linux, or
237`%LOCALAPPDATA%\AgentRelay\config\agent.json` on Windows.
238
239The local client source remains in the sibling Agent Relay project identified
240by `dashboard/agent/source.json`. `tools/deploy.py` bundles it into each frozen
241release before computing its digest. For a direct dashboard build or local
242preview, run `pnpm --dir dashboard install --frozen-lockfile` and
243`python3 tools/build-agent.py` first. The generated `relay.mjs` is a deployment
244artifact and is not checked into this repository.
service/copyparty/copyparty.conf+4-3
......@@ -15,12 +15,13 @@
1515
1616 xff-src: lan
1717 rproxy: 1
18 auth-ord: pw,idp,ipu
1819 idp-h-usr: user-name
1920 idp-h-grp: user-groups
2021 idp-h-key: STUDIO_IDP_HEADER
21 idp-login: /snow.oauth2/sign_in?rd={dst}
22 idp-logout: /snow.oauth2/sign_out
23 idp-login-t: with sso (snow sign on)
22 idp-login: /auth/file/sign-in?rd={dst}
23 idp-logout: /auth/file/sign-out
24 idp-login-t: with snow globe
2425 html-head: <link rel="stylesheet" href="/.static/copyparty.css">
2526
2627[/]
tools/build-agent.py created+29
......@@ -0,0 +1,29 @@
1#!/usr/bin/env python3
2import argparse
3import json
4from pathlib import Path
5import subprocess
6
7
8def build(repo, destination, manifest):
9 spec = json.loads(manifest.read_text())
10 source = (repo / "dashboard/agent" / spec["source"]).resolve(strict=True)
11 entry = (source / spec["entry"]).resolve(strict=True)
12 if not source.is_relative_to(repo.parent) or not entry.is_relative_to(source):
13 raise ValueError("agent source must stay inside the workspace")
14 destination.parent.mkdir(parents=True, exist_ok=True)
15 subprocess.run([
16 str(repo / "dashboard/node_modules/.bin/esbuild"), str(entry),
17 "--bundle", "--platform=node", "--format=esm", "--target=node24",
18 "--external:bufferutil", "--external:utf-8-validate",
19 "--banner:js=import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
20 "--outfile=" + str(destination),
21 ], check=True)
22
23
24if __name__ == "__main__":
25 parser = argparse.ArgumentParser()
26 parser.add_argument("--output", type=Path)
27 args = parser.parse_args()
28 repo = Path(__file__).resolve().parent.parent
29 build(repo, args.output or repo / "dashboard/agent/relay.mjs", repo / "dashboard/agent/source.json")
tools/dashboard-agent-test.py created+175
......@@ -0,0 +1,175 @@
1#!/usr/bin/env python3
2import argparse
3import fcntl
4import json
5import os
6from pathlib import Path
7import pty
8import re
9import select
10import shlex
11import sqlite3
12import subprocess
13import tempfile
14import termios
15import time
16import urllib.error
17import urllib.request
18import uuid
19
20
21def main():
22 parser = argparse.ArgumentParser()
23 parser.add_argument("--url", required=True)
24 parser.add_argument("--output", type=Path)
25 parser.add_argument("--home", type=Path)
26 args = parser.parse_args()
27 origin = args.url.rstrip("/")
28 if os.uname().sysname == "Darwin":
29 existing = subprocess.run(["launchctl", "print", f"gui/{os.getuid()}/net.paperclover.agent-relay"], capture_output=True)
30 assert existing.returncode != 0, "stop the installed agent before running this disposable fixture"
31
32 def http(path, body=None, token=None, status=200):
33 request = urllib.request.Request(origin + path, data=json.dumps(body).encode() if body is not None else None,
34 headers={"Content-Type": "application/json", "Origin": origin, **({"Authorization": "Bearer " + token} if token else {})})
35 try:
36 response = urllib.request.urlopen(request, timeout=15)
37 except urllib.error.HTTPError as error:
38 response = error
39 with response:
40 raw = response.read().decode()
41 assert response.status == status, (path, response.status, raw[:200])
42 try:
43 return json.loads(raw) if raw else None
44 except ValueError:
45 return raw
46
47 with tempfile.TemporaryDirectory(prefix="agent-relay-native-") as temporary:
48 root = args.home.resolve() if args.home else Path(temporary).resolve() / "home with spaces $dollar %percent"
49 root.mkdir(exist_ok=True)
50 assert not (root / ".config/agent-relay/agent.json").exists(), "use a disposable home without an agent pairing"
51 projects = root / "projects"
52 projects.mkdir()
53 codex = root / "codex"
54 codex.mkdir()
55 thread = str(uuid.uuid4())
56 db = sqlite3.connect(codex / "state_5.sqlite")
57 db.execute("CREATE TABLE threads (id TEXT,title TEXT,cwd TEXT,updated_at INTEGER,rollout_path TEXT,archived INTEGER)")
58 db.execute("INSERT INTO threads VALUES (?,?,?,?,?,0)", (thread, "Installer fixture", str(projects), int(time.time()), str(root / "fixture.jsonl")))
59 db.commit()
60 db.close()
61 env = {**os.environ, "HOME": str(root), "XDG_CONFIG_HOME": str(root / ".config"), "XDG_DATA_HOME": str(root / ".local/share"), "CODEX_HOME": str(codex), "CLAUDE_CONFIG_DIR": str(root / "claude")}
62 base = root / "Library/Application Support/AgentRelay" if os.uname().sysname == "Darwin" else root / ".local/share/agent-relay"
63 data = root / ".config/agent-relay"
64 machine = None
65 child = None
66 master = None
67 transcript = ""
68
69 def install(fresh):
70 nonlocal child, master, transcript, machine
71 master, slave = pty.openpty()
72
73 def terminal():
74 os.setsid()
75 fcntl.ioctl(0, termios.TIOCSCTTY, 0)
76
77 child = subprocess.Popen(["sh", "-c", f"curl -fsSL {shlex.quote(origin + '/agent/install.sh')} | sh"], stdin=slave, stdout=slave, stderr=slave, env=env, preexec_fn=terminal)
78 os.close(slave)
79 transcript = ""
80 cursor = 0
81
82 def expect(pattern, timeout=120):
83 nonlocal transcript, cursor
84 deadline = time.monotonic() + timeout
85 while True:
86 match = re.search(pattern, transcript[cursor:])
87 if match:
88 cursor += match.end()
89 return match
90 assert time.monotonic() < deadline, transcript[-1800:]
91 if select.select([master], [], [], .2)[0]:
92 try:
93 chunk = os.read(master, 65536)
94 except OSError:
95 chunk = b""
96 assert chunk, transcript[-1800:]
97 transcript += chunk.decode(errors="replace")
98
99 if fresh:
100 expect(r"Machine name \[.*?\]: ")
101 os.write(master, b"Installer fixture\n")
102 expect(r"Project folder: ")
103 os.write(master, (str(projects) + "\n" if fresh else "\n").encode())
104 if fresh:
105 expect(r"Project folder: ")
106 os.write(master, b"\n")
107 expect(r"Enable desktop control\?.*?: ")
108 os.write(master, b"n\n")
109 if fresh:
110 code = expect(r"link this machine with code ([A-Z0-9]+-[A-Z0-9]+)").group(1)
111 machine = http("/api/mcp/relay/pair", {"code": code})
112 expect(r"Installed\. Agent Relay starts at login\.")
113 deadline = time.monotonic() + 20
114 while child.poll() is None and time.monotonic() < deadline:
115 if select.select([master], [], [], .2)[0]:
116 try:
117 transcript += os.read(master, 65536).decode(errors="replace")
118 except OSError:
119 break
120 assert child.poll() is not None, transcript[-1800:]
121 assert child.returncode == 0, transcript[-1800:]
122 os.close(master)
123 master = None
124
125 def online(expected):
126 deadline = time.monotonic() + 20
127 while time.monotonic() < deadline:
128 machines = http("/api/mcp")["machines"]
129 found = next((item for item in machines if item["id"] == machine["id"]), None)
130 if found and found["online"] == expected:
131 return
132 time.sleep(.2)
133 raise AssertionError("agent connection did not change")
134
135 try:
136 install(True)
137 online(True)
138 config = json.loads((data / "agent.json").read_text())
139 assert config["roots"] == [str(projects)] and not config["desktopWrite"]
140 assert (data / "agent.json").stat().st_mode & 0o777 == 0o600
141 key = http("/api/mcp/relay/keys", {"name": "Installer fixture", "resources": [machine["id"]], "write": True})["key"]
142 result = http(f"/api/v1/machines/{machine['id']}/commands", {"method": "list_threads", "params": {"provider": "codex"}}, key)
143 assert any(item["id"] == thread for item in result["result"]["threads"]), result
144 result = http(f"/api/v1/machines/{machine['id']}/commands", {"method": "start_thread", "params": {"provider": "codex", "cwd": str(root), "message": "fixture"}}, key, status=400)
145 assert "outside the roots" in str(result)
146 install(False)
147 online(True)
148 assert json.loads((data / "agent.json").read_text()) == config
149 manage = [str(base / "node"), str(base / "setup.mjs")]
150 subprocess.run([*manage, "stop"], env=env, check=True, capture_output=True)
151 online(False)
152 subprocess.run([*manage, "start"], env=env, check=True, capture_output=True)
153 online(True)
154 subprocess.run([*manage, "uninstall"], env=env, check=True, capture_output=True)
155 online(False)
156 assert (data / "agent.json").exists()
157 report = {"platform": os.uname().sysname, "curl_pipe_prompts": "passed", "pairing": "passed", "native_login_startup": "passed", "private_credentials": "passed", "captured_cli_environment": "passed", "allowed_folders": "passed", "reinstall_keeps_identity": "passed", "stop_start_uninstall": "passed", "spaces_dollars_percent_in_paths": "passed"}
158 if args.output:
159 args.output.write_text(json.dumps(report, indent=2) + "\n")
160 print(json.dumps(report))
161 finally:
162 if (base / "setup.mjs").exists():
163 subprocess.run([str(base / "node"), str(base / "setup.mjs"), "uninstall"], env=env, capture_output=True)
164 if child and child.poll() is None:
165 os.killpg(child.pid, 9)
166 child.wait(timeout=10)
167 if master is not None:
168 os.close(master)
169 if machine:
170 request = urllib.request.Request(origin + "/api/mcp/relay/machines/" + machine["id"], method="DELETE", headers={"Origin": origin})
171 urllib.request.urlopen(request, timeout=10).close()
172
173
174if __name__ == "__main__":
175 main()
tools/dashboard-auth-test.py created+196
......@@ -0,0 +1,196 @@
1#!/usr/bin/env python3
2import argparse
3import base64
4import hashlib
5import http.client
6import json
7import os
8from pathlib import Path
9import socket
10import sqlite3
11import subprocess
12import tempfile
13import time
14import uuid
15from cryptography.hazmat.primitives import hashes
16from cryptography.hazmat.primitives.asymmetric import ec
17from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
18
19
20def b64(value):
21 return base64.urlsafe_b64encode(value).decode().rstrip('=')
22
23
24def cbor(value):
25 def header(kind, size):
26 if size < 24: return bytes([kind * 32 + size])
27 width = 1 if size < 256 else 2 if size < 65536 else 4
28 return bytes([kind * 32 + {1: 24, 2: 25, 4: 26}[width]]) + size.to_bytes(width, 'big')
29 if isinstance(value, int): return header(0 if value >= 0 else 1, value if value >= 0 else -value - 1)
30 if isinstance(value, bytes): return header(2, len(value)) + value
31 if isinstance(value, str): return header(3, len(value.encode())) + value.encode()
32 if isinstance(value, dict): return header(5, len(value)) + b''.join(cbor(k) + cbor(v) for k, v in value.items())
33 raise TypeError(type(value))
34
35
36def main():
37 parser = argparse.ArgumentParser()
38 parser.add_argument('--binary', type=Path, default=Path('dashboard/target/debug/home-dashboard'))
39 args = parser.parse_args()
40 origin, file, rp = 'https://snowglobe.paperclover.net', 'https://file.paperclover.net', 'auth.paperclover.net'
41 name, password, actor = 'auth-test', uuid.uuid4().hex, str(uuid.uuid4())
42 group = str(uuid.uuid4())
43 salt = os.urandom(16)
44 digest = Argon2id(salt=salt, length=32, iterations=5, lanes=1, memory_cost=7168).derive(password.encode())
45 private = ec.generate_private_key(ec.SECP256R1())
46 public = private.public_key().public_numbers()
47 key = cbor({1: 2, 3: -7, -1: 1, -2: public.x.to_bytes(32, 'big'), -3: public.y.to_bytes(32, 'big')})
48 credential_id = os.urandom(32)
49 export = {'rpId': rp, 'roles': [{'id': group, 'name': 'infra-admin'}], 'users': [{
50 'id': actor, 'username': name, 'enabled': True, 'email': 'auth-test@example.invalid', 'emailVerified': True,
51 'firstName': 'Auth', 'lastName': 'Test', 'createdTimestamp': 1, 'requiredActions': [], 'attributes': {}, 'roles': [group],
52 'credentials': [
53 {'id': str(uuid.uuid4()), 'type': 'password', 'createdDate': 1, 'credentialData': {'algorithm': 'argon2', 'hashIterations': 5,
54 'additionalParameters': {'type': ['id'], 'memory': ['7168'], 'parallelism': ['1']}},
55 'secretData': {'salt': base64.b64encode(salt).decode(), 'value': base64.b64encode(digest).decode()}},
56 {'id': str(uuid.uuid4()), 'type': 'webauthn-passwordless', 'userLabel': 'imported', 'createdDate': 1,
57 'credentialData': {'credentialId': base64.b64encode(credential_id).decode(), 'credentialPublicKey': b64(key), 'counter': 0, 'transports': ['internal']}}
58 ]}]}
59 with tempfile.TemporaryDirectory(prefix='dashboard-auth-') as temporary:
60 data = Path(temporary).resolve()
61 proof = uuid.uuid4().hex + uuid.uuid4().hex
62 (data / 'proof').write_text(proof)
63 (data / 'source.json').write_text(json.dumps(export))
64 environment = {**os.environ, 'STUDIO_DOMAIN': 'paperclover.net', 'STUDIO_DATA_DIR': str(data),
65 'STUDIO_PUBLIC_ORIGIN': origin, 'STUDIO_AUTH_RP_ID': rp, 'STUDIO_FILE_ORIGIN': file,
66 'STUDIO_WEB_DIR': str(Path('dashboard/dist').resolve()), 'STUDIO_PROXY_TOKEN_FILE': str(data / 'proof'), 'STUDIO_AUTH_REQUIRED': '1'}
67 binary = str(args.binary.resolve())
68 imported = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True)
69 assert imported.returncode == 0, imported.stderr
70 assert json.loads(imported.stdout) == {'accounts': 1, 'credentials': 2}
71 again = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True)
72 assert again.returncode == 0
73 changed = json.loads(json.dumps(export)); changed['users'][0]['username'] = 'different'
74 (data / 'different.json').write_text(json.dumps(changed))
75 rejected = subprocess.run([binary, '--import-accounts', str(data / 'different.json')],env=environment,capture_output=True)
76 assert rejected.returncode != 0
77 with socket.socket() as available:
78 available.bind(('127.0.0.1', 0)); port = available.getsockname()[1]
79 environment['PORT'] = str(port)
80 log = (data / 'server.log').open('wb')
81 server = None
82
83 def start():
84 nonlocal server
85 server = subprocess.Popen([binary], env=environment, stdout=log, stderr=log)
86 deadline = time.monotonic() + 15
87 while True:
88 try:
89 with socket.create_connection(('127.0.0.1', port), timeout=.1): break
90 except OSError:
91 assert server.poll() is None, (data / 'server.log').read_text()[-2000:]
92 if time.monotonic() > deadline: raise AssertionError('dashboard did not start')
93 time.sleep(.05)
94
95 def stop():
96 server.terminate(); server.wait(timeout=10)
97
98 def request(path, method='GET', body=None, cookies=None, status=200, extra=None, host=origin):
99 headers = {'Studio-Proxy-Token': proof, 'Host': host.split('://')[1], 'X-Studio-Client-IP': '127.0.0.1'}
100 if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/json'})
101 if cookies: headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items())
102 headers.update(extra or {})
103 connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15)
104 connection.request(method, path, body=json.dumps(body) if body is not None else None, headers=headers)
105 response = connection.getresponse(); content = response.read(); fields = dict(response.getheaders()); connection.close()
106 assert response.status == status, (path, response.status, content[:200], (data / "server.log").read_text()[-1000:])
107 if cookies is not None and 'set-cookie' in fields:
108 cookie = fields['set-cookie']; assert 'Secure; HttpOnly; SameSite=Lax' in cookie and 'Domain=' not in cookie
109 key, value = cookie.split(';', 1)[0].split('=', 1); cookies[key] = value
110 return json.loads(content) if fields.get('content-type', '').startswith('application/json') and content else fields
111
112 cookies = {}
113 start()
114 try:
115 request('/api/me', status=401, extra={'User-Name': name, 'User-Groups': 'infra-admin'})
116 request('/auth/status', status=403, extra={'Studio-Proxy-Token': 'wrong'})
117 csrf = request('/auth/status', cookies=cookies)['csrf']
118 login = {'csrf': csrf, 'username': name, 'password': password, 'next': '/users'}
119 request('/auth/password', 'POST', login, cookies, 403, {'Origin': 'https://evil.example'})
120 request('/auth/password', 'POST', {**login, 'csrf': 'wrong'}, cookies, 403)
121 request('/auth/password', 'POST', {**login, 'password': 'wrong'}, cookies, 401)
122 assert request('/auth/password', 'POST', login, cookies)['next'] == '/users'
123 assert 'admin' in request('/api/me', cookies=cookies)['sections']
124 request('/api/users', 'POST', {}, cookies, 403, {'Origin': 'https://evil.example'})
125 assert request('/auth/password', 'POST', {**login, 'next': '//evil.example'}, cookies)['next'] == '/'
126 # Import's password format must verify with the original Keycloak parameters.
127 with sqlite3.connect(data / 'accounts.sqlite') as db:
128 phc = json.loads(db.execute("SELECT data FROM credentials WHERE kind='password'").fetchone()[0])['phc']
129 assert '$m=7168,t=5,p=1$' in phc
130 other = {}; csrf2 = request('/auth/status', cookies=other)['csrf']
131 begun = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
132 assert begun['options']['publicKey']['rpId'] == rp
133
134 def assertion(begin, origin_value=origin, flags=29, counter=1, handle=actor.encode()):
135 client = json.dumps({'type': 'webauthn.get', 'challenge': begin['options']['publicKey']['challenge'], 'origin': origin_value, 'crossOrigin': False}).encode()
136 authenticator = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + counter.to_bytes(4, 'big')
137 signature = private.sign(authenticator + hashlib.sha256(client).digest(), ec.ECDSA(hashes.SHA256()))
138 return {'id': b64(credential_id), 'rawId': b64(credential_id), 'type': 'public-key',
139 'response': {'authenticatorData': b64(authenticator), 'clientDataJSON': b64(client), 'signature': b64(signature), 'userHandle': b64(handle)}}
140
141 signed = {'csrf': csrf2, 'token': begun['token'], 'credential': assertion(begun)}
142 request('/auth/passkey/finish', 'POST', signed, other)
143 request('/auth/passkey/finish', 'POST', signed, other, 403)
144 for options in [{'origin_value': 'https://evil.example'}, {'flags': 25}, {'flags': 21}, {'counter': 1}, {'handle': uuid.uuid4().bytes}]:
145 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
146 request('/auth/passkey/finish', 'POST', {'csrf': csrf2, 'token': begin['token'], 'credential': assertion(begin, **({'counter': 2} | options))}, other, 401)
147 begin = request('/auth/passkey/start', 'POST', {'csrf': csrf2, 'username': name}, other)
148 tampered = assertion(begin, counter=2); tampered['response']['signature'] = b64(b'forged')
149 request('/auth/passkey/finish', 'POST', {'csrf':csrf2, 'token':begin['token'], 'credential':tampered}, other, 401)
150 registration = request('/auth/passkey/register', 'POST', {'csrf': csrf}, cookies)
151 new_id = os.urandom(32)
152 client = json.dumps({'type': 'webauthn.create', 'challenge': registration['options']['publicKey']['challenge'], 'origin': origin, 'crossOrigin': False}).encode()
153 authenticator = hashlib.sha256(rp.encode()).digest() + bytes([93]) + bytes(4) + bytes(16) + len(new_id).to_bytes(2, 'big') + new_id + key
154 credential = {'id': b64(new_id), 'rawId': b64(new_id), 'type': 'public-key', 'response': {
155 'clientDataJSON': b64(client), 'attestationObject': b64(cbor({'fmt': 'none', 'authData': authenticator, 'attStmt': {}})), 'transports': ['internal']}}
156 request('/auth/passkey/save', 'POST', {'csrf': csrf, 'token': registration['token'], 'credential': credential, 'label': 'new passkey'}, cookies, 204)
157 request('/auth/passkey/save', 'POST', {'csrf': csrf, 'token': registration['token'], 'credential': credential}, cookies, 403)
158 file_cookies = {}
159 handoff = request('/auth/file/sign-in?rd=%2Fclover%2FPublic%2F', cookies=file_cookies, status=302, host=file)
160 flow = file_cookies['__Host-snow-flow']
161 callback = request('/auth/continue?flow=' + flow, cookies=cookies, status=302)['location']
162 request('/auth/file/callback?' + callback.split('?', 1)[1], cookies={}, status=403, host=file)
163 finished = request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=302, host=file)
164 assert finished['location'] == file + '/clover/Public/'
165 request('/auth/file/callback?' + callback.split('?', 1)[1], cookies=file_cookies, status=403, host=file)
166 request('/auth/file/check', cookies=file_cookies, status=204, host=file)
167 request('/auth/file/check', cookies=cookies, status=401, host=file)
168 request('/api/me', cookies=file_cookies, status=401)
169 request('/auth/file/sign-in?rd=https://evil.example/', status=400, host=file)
170 invitation = request('/api/users', 'POST', {'profile': {'username': 'invited', 'email': '', 'firstName': 'Invited', 'lastName': 'Person'}, 'groups': [], 'setup': {'kind': 'invite'}}, cookies, 201)
171 setup = invitation['url'].split('setup=', 1)[1]
172 newcomer = {}; new_csrf = request('/auth/status?setup=' + setup, cookies=newcomer)['csrf']
173 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer)
174 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer, 410)
175 request('/api/users', cookies=newcomer, status=403)
176 request('/api/users/' + actor, 'PATCH', {'enabled': False}, cookies, 400)
177 request('/api/users/' + actor + '/groups/' + group, 'DELETE', {}, cookies, 400)
178 replacement = request('/api/users/' + invitation['id'] + '/setup-link', 'POST', {}, cookies)['url']
179 request('/api/users/' + invitation['id'] + '/setup-link', 'DELETE', {}, cookies, 204)
180 request('/auth/status?' + replacement.split('?', 1)[1], cookies={}, status=410)
181 request('/api/users/' + invitation['id'], 'PATCH', {'enabled': False}, cookies, 204)
182 request('/api/me', cookies=newcomer, status=401)
183 stop(); start()
184 request('/api/me', cookies=cookies)
185 request('/auth/file/check', cookies=file_cookies, status=204, host=file)
186 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)
187 request('/api/me', cookies=cookies, status=401)
188 request('/auth/file/check', cookies=file_cookies, status=401, host=file)
189 print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed'}))
190 finally:
191 if server and server.poll() is None: stop()
192 log.close()
193
194
195if __name__ == '__main__':
196 main()
tools/dashboard-backup-test.py created+71
......@@ -0,0 +1,71 @@
1#!/usr/bin/env python3
2import importlib
3import json
4from pathlib import Path
5import sqlite3
6import tempfile
7
8
9def main():
10 data = importlib.import_module('data')
11 with tempfile.TemporaryDirectory(prefix='dashboard-backup-') as temporary:
12 data.STATE = Path(temporary)
13 data.BACKUPS = data.STATE / 'backups'
14 live = data.STATE / 'dashboard'
15 live.mkdir()
16 connections = []
17 for name in ['accounts.sqlite', 'connections.sqlite']:
18 db = sqlite3.connect(live / name)
19 db.execute('PRAGMA journal_mode=WAL')
20 db.execute('CREATE TABLE durable (value TEXT)')
21 db.execute('INSERT INTO durable VALUES (?)', (name,))
22 db.commit()
23 connections.append(db)
24 (live / 'pictures').mkdir()
25 (live / 'pictures' / 'avatar').write_bytes(b'original picture')
26 backup_id = '20261005T000000Z-abcdef'
27 directory = data.BACKUPS / backup_id
28 directory.mkdir(parents=True)
29 files = data.backup_dashboard(directory / 'dashboard')
30 assert set(files) == {'accounts.sqlite', 'connections.sqlite', 'pictures/avatar'}
31 for name in ['accounts.sqlite', 'connections.sqlite']:
32 with sqlite3.connect(directory / 'dashboard' / name) as db:
33 assert db.execute('SELECT value FROM durable').fetchall() == [(name,)]
34 revision = 'a' * 16
35 (directory / 'manifest.json').write_text(json.dumps({'id': backup_id, 'fromRelease': revision, 'services': {'dashboard': {'files': files}}}))
36 data.current_release = lambda: Path('/releases') / revision
37 lifecycle = []
38 def run(*args, **kwargs):
39 lifecycle.append(args)
40 if args == ('systemctl', 'stop', 'studio-dashboard'):
41 for db in connections:
42 db.close()
43 data.run = run
44 for db in connections:
45 db.execute('DELETE FROM durable')
46 db.execute("INSERT INTO durable VALUES ('later change')")
47 db.commit()
48 (live / 'pictures' / 'avatar').write_bytes(b'later picture')
49 data.restore(backup_id, 'dashboard')
50 assert lifecycle == [('systemctl', 'stop', 'studio-dashboard'), ('systemctl', 'start', 'studio-dashboard'), ('systemctl', 'is-active', '--quiet', 'studio-dashboard')]
51 assert (live / 'pictures' / 'avatar').read_bytes() == b'original picture'
52 for name in ['accounts.sqlite', 'connections.sqlite']:
53 with sqlite3.connect(live / name) as db:
54 assert db.execute('SELECT value FROM durable').fetchall() == [(name,)]
55 safety = next(data.BACKUPS.glob('before-restore-*/dashboard/' + name))
56 with sqlite3.connect(safety) as db:
57 assert db.execute('SELECT value FROM durable').fetchall() == [('later change',)]
58 lifecycle.clear()
59 (directory / 'dashboard' / 'pictures/avatar').write_bytes(b'corrupted backup')
60 try:
61 data.restore(backup_id, 'dashboard')
62 except ValueError:
63 pass
64 else:
65 raise AssertionError('corrupt backup accepted')
66 assert not lifecycle
67 print(json.dumps({'live_wal_backup': 'passed', 'account_and_connection_restore': 'passed', 'safety_copy': 'passed', 'corrupt_backup_refused_before_stop': 'passed'}))
68
69
70if __name__ == '__main__':
71 main()
tools/dashboard-mcp-test.py+1-1
......@@ -67,7 +67,7 @@ def main():
6767 request = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0],
6868 "code_challenge_method": "S256", "code_challenge": challenge, "resource": resource, "scope": scope, "state": marker}
6969 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(request), status=302)
70 pending = urllib.parse.parse_qs(urllib.parse.urlsplit(headers["Location"]).query)["request"][0]
70 pending = urllib.parse.urlsplit(headers["Location"]).path.removeprefix("/connect/")
7171 path = "/api/mcp/consent/" + pending
7272 details, _ = http(path, actor=actor)
7373 assert details["client"] == client["client_name"]
tools/dashboard-relay-test.py+1-1
......@@ -306,7 +306,7 @@ def main():
306306 fields = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "resource": resource, "scope": "sessions:read sessions:write offline_access",
307307 "code_challenge_method": "S256", "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")}
308308 _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(fields), status=302)
309 request_id = urllib.parse.parse_qs(urllib.parse.urlsplit(headers["Location"]).query)["request"][0]
309 request_id = urllib.parse.urlsplit(headers["Location"]).path.removeprefix("/connect/")
310310 consent_path = "/api/mcp/consent/" + request_id
311311 details, _ = http(consent_path, actor=names[0])
312312 assert {r["id"] for r in details["resources"]} == {first["id"], second["id"]}
tools/dashboard-shale-link-test.py+51-5
......@@ -49,6 +49,7 @@ def main():
4949 marker = 'shale-link-' + uuid.uuid4().hex
5050 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')]
5151 ids = []
52 sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts}
5253
5354 class TLS(urllib.request.HTTPSHandler):
5455 def https_open(self, request):
......@@ -76,6 +77,7 @@ def main():
7677 _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method,
7778 body=json.dumps(body).encode() if body is not None else None,
7879 headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor,
80 'Cookie': '__Host-snow-session=' + sessions[actor],
7981 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status)
8082 return json.loads(body) if body and status < 400 else body or None
8183
......@@ -132,7 +134,7 @@ def main():
132134 assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', []))
133135 assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', []))
134136 if status == 303:
135 assert headers['Location'] == origin + '/mcp' + ('?request=' + pending if pending else '')
137 assert headers['Location'] == origin + ('/connect/' + pending if pending else '/mcp/settings/shale')
136138
137139 def backend_session(value, status):
138140 return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status)
......@@ -171,7 +173,7 @@ def main():
171173 _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code',
172174 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256',
173175 'code_challenge': challenge, 'resource': origin + '/mcp/shale', 'scope': scope, 'state': marker}), status=302)
174 pending = urllib.parse.parse_qs(urllib.parse.urlsplit(headers['Location']).query)['request'][0]
176 pending = urllib.parse.urlsplit(headers['Location']).path.removeprefix('/connect/')
175177 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)
176178 assert details['client'] == marker
177179 api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403)
......@@ -180,13 +182,13 @@ def main():
180182 def consent(client, index, repository, scope):
181183 pending, verifier, details = pending_request(client, index, scope)
182184 available = {r['id'] for r in details['resources']}
183 assert details['linked'] and repository in available, details
185 assert details['linked'] and (repository == 'all' or repository in available), details
184186 if index == 0:
185 assert available == {'alpha', 'beta'}, details
187 assert {'alpha', 'beta'} <= available, details
186188 path = '/api/mcp/consent/' + pending
187189 api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']})
188190 api(accounts[index][0], 'POST', path, 403 if len(available) >= 2 else 400, {'resources': [repository, repository]})
189 result = api(accounts[index][0], 'POST', path, body={'resources': [repository]})
191 result = api(accounts[index][0], 'POST', path, body={'resources': 'all' if repository == 'all' else [repository]})
190192 query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query)
191193 assert query['state'] == [marker]
192194 tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'],
......@@ -245,6 +247,13 @@ def main():
245247 found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true')
246248 assert len(found) == 1
247249 ids.append(found[0]['id'])
250 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
251 profile = {key: found[0].get(key) for key in ['username', 'firstName', 'lastName', 'email', 'emailVerified', 'enabled']}
252 profile['requiredActions'] = []
253 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (ids[-1], json.dumps(profile)))
254 stamp = int(time.time())
255 db.execute('INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)',
256 (hashlib.sha256(sessions[name].encode()).hexdigest(), ids[-1], 'dashboard', stamp + 3600, '127.0.0.1', stamp, stamp, stamp))
248257 assert all(api(name)['shale'] is None for name, _ in accounts)
249258 api(accounts[0][0], 'POST', '/api/mcp/shale', status=200)
250259 old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect']
......@@ -288,7 +297,37 @@ def main():
288297 'comment_issue', 'set_issue_status', 'set_issue_title'}
289298 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)
290299 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}
300 grant = next(value for value in records('grant:').values() if value['scopes'] == ['shale:read', 'offline_access'] and value['user'] == ids[0])
301 endpoint = '/api/mcp/connections/' + grant['id']
302 details = api(accounts[0][0], path=endpoint)
303 assert details['linked'] and details['selected'] == ['alpha']
304 assert {'alpha', 'beta'} <= {resource['id'] for resource in details['resources']}
305 api(accounts[1][0], path=endpoint, status=404)
306 api(accounts[1][0], 'POST', endpoint, 404, {'resources': ['foreign']})
307 api(accounts[0][0], 'POST', endpoint, 403, {'resources': ['foreign']})
308 api(accounts[0][0], 'POST', endpoint, 403, {'resources': ['alpha', 'alpha']})
309 api(accounts[0][0], 'POST', endpoint, 400, {'resources': []})
310 api(accounts[0][0], 'POST', endpoint, 204, {'resources': ['beta']})
311 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'beta'}
312 call(read, 'list_issues', {'repository': 'alpha'}, error=True)
313 call(read, 'create_issue', {'repository': 'beta', 'title': 'REFUSED'}, error=True)
314 api(accounts[0][0], 'POST', endpoint, 204, {'resources': 'all'})
315 assert api(accounts[0][0], path=endpoint)['selected'] == 'all'
316 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha', 'beta'}
317 call(read, 'list_issues', {'repository': 'foreign'}, error=True)
318 call(read, 'create_issue', {'repository': 'beta', 'title': 'REFUSED'}, error=True)
319 nested = 'userscripts/nested-fixture'
320 repository(0, nested)
321 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha', 'beta', nested}
322 assert not call(read, 'list_issues', {'repository': nested})['issues']
323 api(accounts[0][0], 'POST', endpoint, 204, {'resources': [nested]})
324 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {nested}
325 call(read, 'list_issues', {'repository': 'beta'}, error=True)
326 api(accounts[0][0], 'POST', endpoint, 204, {'resources': ['alpha']})
327
291328 assert {repo['id'] for repo in call(other['access_token'], 'list_repositories')['repositories']} == {'foreign'}
329 all_repositories = consent(oauth_client, 0, 'all', 'shale:read')
330 assert {repo['id'] for repo in call(all_repositories['access_token'], 'list_repositories')['repositories']} == {'alpha', 'beta', nested}
292331 assert not call(read, 'list_issues', {'repository': 'alpha'})['issues']
293332 for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'),
294333 (write, 'alpha/../foreign'), (write, 'https://other.invalid')]:
......@@ -392,6 +431,7 @@ def main():
392431 'native_issue_labels_read': True,
393432 'committed_write_lost_response_reported_without_replay': True,
394433 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,
434 'all_repository_approval_and_dynamic_access': True, 'nested_repository_paths': True, 'existing_token_resource_edits': True,
395435 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}
396436 finally:
397437 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
......@@ -408,6 +448,12 @@ def main():
408448 keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE')
409449 except Exception as error:
410450 cleanup_errors.append(error)
451 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
452 db.execute('PRAGMA foreign_keys=ON')
453 for identity in ids:
454 for table in ['sessions', 'credentials', 'memberships']:
455 db.execute(f'DELETE FROM {table} WHERE user_id=?', (identity,))
456 db.execute('DELETE FROM users WHERE id=?', (identity,))
411457 if cleanup_errors:
412458 raise cleanup_errors[0]
413459 result['owned_users_and_credentials_removed'] = True
tools/data.py+47-1
......@@ -1,6 +1,6 @@
11#!/usr/bin/env python3
22import argparse
3from contextlib import contextmanager
3from contextlib import closing, contextmanager
44from datetime import datetime, timezone
55import hashlib
66import json
......@@ -9,6 +9,7 @@ from pathlib import Path
99import re
1010import secrets
1111import shutil
12import sqlite3
1213import subprocess
1314import time
1415
......@@ -140,6 +141,28 @@ def cloned_postgres(snapshot, clone, mountpoint):
140141 run("zfs", "destroy", clone)
141142
142143
144def backup_dashboard(directory):
145 source = STATE / "dashboard"
146 directory.mkdir(mode=0o700)
147 for path in sorted(source.glob("*.sqlite")):
148 target = directory / path.name
149 with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as live, closing(sqlite3.connect(target)) as copy:
150 live.backup(copy)
151 if copy.execute("PRAGMA quick_check").fetchone() != ("ok",):
152 raise ValueError("Dashboard database backup failed its integrity check")
153 if copy.execute("PRAGMA journal_mode=DELETE").fetchone() != ("delete",):
154 raise ValueError("Dashboard backup could not leave WAL mode")
155 target.chmod(0o600)
156 for suffix in ["-wal", "-shm"]:
157 target.with_name(target.name + suffix).unlink(missing_ok=True)
158 if (source / "pictures").exists():
159 shutil.copytree(source / "pictures", directory / "pictures", symlinks=True)
160 paths = sorted(path for path in directory.rglob("*") if path.is_file())
161 if any(path.is_symlink() for path in directory.rglob("*")):
162 raise ValueError("Dashboard backup contains a symlink")
163 return {str(path.relative_to(directory)): checksum(path) for path in paths}
164
165
143166def backup(from_release, to_release):
144167 if not RELEASE_ID.fullmatch(from_release) or not RELEASE_ID.fullmatch(to_release):
145168 raise ValueError("Invalid release ID")
......@@ -177,6 +200,8 @@ def backup(from_release, to_release):
177200 snapshots = [f"{dataset}@{snapshot}" for dataset in sorted(datasets)]
178201 created = False
179202 try:
203 if (STATE / "dashboard").is_dir():
204 manifest["services"]["dashboard"] = {"files": backup_dashboard(directory / "dashboard")}
180205 if snapshots:
181206 run("zfs", "snapshot", *snapshots)
182207 created = True
......@@ -231,6 +256,27 @@ def restore(backup_id, service):
231256 if service == "postgres":
232257 raise ValueError("Postgres serves multiple services; restore a specific database owner")
233258 entry = manifest["services"][service]
259 if service == "dashboard":
260 source = directory / "dashboard"
261 for name, digest in entry["files"].items():
262 path = source / name
263 if not path.resolve().is_relative_to(source.resolve()) or not path.is_file() or checksum(path) != digest:
264 raise ValueError("Dashboard backup is missing or changed")
265 run("systemctl", "stop", "studio-dashboard")
266 safety = BACKUPS / ("before-restore-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3))
267 safety.mkdir(mode=0o700)
268 files = backup_dashboard(safety / "dashboard")
269 (safety / "manifest.json").write_text(json.dumps({"files": files}) + "\n")
270 root = STATE / "dashboard"
271 for path in root.glob("*.sqlite*"):
272 path.unlink()
273 if (root / "pictures").exists():
274 shutil.rmtree(root / "pictures")
275 shutil.copytree(source, root, dirs_exist_ok=True)
276 run("systemctl", "start", "studio-dashboard")
277 run("systemctl", "is-active", "--quiet", "studio-dashboard")
278 print(f"restored=dashboard backup={backup_id} safety={safety.name}")
279 return
234280 dataset = entry.get("dataset")
235281 if dataset and dataset_for(service) != dataset:
236282 raise ValueError("Service dataset changed since backup")
tools/deploy.py+3
......@@ -2,6 +2,7 @@
22import argparse
33import json
44import os
5from importlib import import_module
56from pathlib import Path
67import re
78import shlex
......@@ -113,6 +114,8 @@ def upload(main=False):
113114 shutil.copytree(origin, destination, symlinks=True)
114115 else:
115116 shutil.copy2(origin, destination)
117 if (snapshot / "dashboard/agent/source.json").exists():
118 import_module("build-agent").build(REPO, snapshot / "dashboard/agent/relay.mjs", snapshot / "dashboard/agent/source.json")
116119 digest = tree_digest(snapshot)
117120 release = digest[:16]
118121 remote_release = REMOTE / "releases" / release
tools/import-dashboard-auth.py created+59
......@@ -0,0 +1,59 @@
1#!/usr/bin/env python3
2import argparse
3import hashlib
4import json
5import os
6from pathlib import Path
7import subprocess
8
9
10parser = argparse.ArgumentParser(description="Copy Keycloak accounts into a private dashboard import")
11parser.add_argument("--host", required=True)
12parser.add_argument("--output", required=True, type=Path)
13args = parser.parse_args()
14if args.output.exists():
15 parser.error("output already exists")
16
17remote = r'''
18import json, subprocess, urllib.request
19request = urllib.request.Request("http://127.0.0.1:4646/v1/job/postgres/allocations", headers={"X-Nomad-Token":open("/var/lib/studio/nomad.token").read().strip()})
20allocations = [a["ID"] for a in json.load(urllib.request.urlopen(request)) if a["ClientStatus"] == "running" and a["DesiredStatus"] == "run"]
21assert len(allocations) == 1
22containers = [line.split()[0] for line in subprocess.check_output(["podman", "ps", "--format", "{{.ID}} {{.Names}}"], text=True).splitlines() if line.split()[1].endswith(allocations[0])]
23assert len(containers) == 1
24sql = """
25BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
26SELECT json_build_object(
27 'issuer','https://auth.paperclover.net/realms/master',
28 'rpId','auth.paperclover.net',
29 'roles',(SELECT coalesce(json_agg(json_build_object('id',id,'name',name)), '[]') FROM keycloak_role WHERE realm_id=(SELECT id FROM realm WHERE name='master') AND client_role=false),
30 'users',(SELECT coalesce(json_agg(json_build_object(
31 'id',u.id,'username',u.username,'email',u.email,'firstName',u.first_name,'lastName',u.last_name,
32 'enabled',u.enabled,'emailVerified',u.email_verified,'createdTimestamp',u.created_timestamp,
33 'requiredActions',(SELECT coalesce(json_agg(required_action),'[]') FROM user_required_action WHERE user_id=u.id),
34 'attributes',(SELECT coalesce(json_object_agg(name,vals),'{}') FROM (SELECT name,json_agg(value) AS vals FROM user_attribute WHERE user_id=u.id GROUP BY name)a),
35 'roles',(SELECT coalesce(json_agg(role_id),'[]') FROM user_role_mapping WHERE user_id=u.id),
36 'credentials',(SELECT coalesce(json_agg(json_build_object('id',id,'type',type,'userLabel',user_label,'createdDate',created_date,'credentialData',credential_data::json,'secretData',secret_data::json)),'[]') FROM credential WHERE user_id=u.id)
37 )),'[]') FROM user_entity u WHERE realm_id=(SELECT id FROM realm WHERE name='master'))
38);
39COMMIT;
40"""
41result = subprocess.run(["podman", "exec", "-i", containers[0], "psql", "-U", "postgres", "-d", "keycloak_next", "-tA", "-v", "ON_ERROR_STOP=1"], input=sql, text=True, capture_output=True, check=True)
42print(next(line for line in result.stdout.splitlines() if line.startswith('{')))
43'''
44result = subprocess.run(["ssh", "-o", "BatchMode=yes", args.host, "python3", "-"], input=remote, text=True, capture_output=True, check=True)
45data = json.loads(result.stdout)
46if not data["users"]:
47 raise ValueError("source realm has no accounts")
48content = (json.dumps(data, separators=(",", ":")) + "\n").encode()
49args.output.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
50with os.fdopen(os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as output:
51 output.write(content)
52 output.flush()
53 os.fsync(output.fileno())
54counts = {}
55for user in data["users"]:
56 for credential in user["credentials"]:
57 kind = credential["type"]
58 counts[kind] = counts.get(kind, 0) + 1
59print(json.dumps({"accounts": len(data["users"]), "credentials": counts, "sha256": hashlib.sha256(content).hexdigest()}))
tools/router.py+29-28
......@@ -50,6 +50,10 @@ def render(token):
5050 dashboard_proof = file.read().strip()
5151 if not re.fullmatch(r"[0-9a-fA-F]{64}", dashboard_proof):
5252 raise ValueError("invalid dashboard proxy token")
53 auth_host = "auth." + os.environ["STUDIO_DOMAIN"]
54 origins = json.dumps({"origins": ["https://snowglobe." + os.environ["STUDIO_DOMAIN"]]}, separators=(",", ":"))
55 webauthn = [" handle /.well-known/webauthn {", ' header Content-Type application/json',
56 f" respond {json.dumps(origins)} 200", " }"]
5357 routes = {}
5458 internal_services = {}
5559 auth_upstreams = set()
......@@ -130,7 +134,11 @@ def render(token):
130134 service = next(iter(route["services"]))
131135 if not re.fullmatch(r"[a-z][a-z0-9-]*", service):
132136 raise ValueError(f"invalid service name: {service}")
137 if service == "keycloak" and host == auth_host:
138 lines += webauthn
133139 if service == "shale":
140 lines += [" @userscript path_regexp userscript ^/userscripts/discord-pluralkit-predict(/.*)?$",
141 " redir @userscript /discord-pluralkit-predict{re.userscript.1}?{query} 308"]
134142 port = int(os.environ["STUDIO_DASHBOARD_PORT"])
135143 if not 1 <= port <= 65535:
136144 raise ValueError("invalid dashboard port for Shale linking")
......@@ -203,11 +211,19 @@ def render(token):
203211 *proxy(upstreams, " "), " }",
204212 " handle_response {", " respond 403", " }", " }", " }", "}",
205213 ]
206 elif headers and auth_upstreams:
207 auth = " ".join(sorted(auth_upstreams))
208 lines += [" handle /snow.oauth2/* {", *proxy(auth, " "), " }", " handle {"]
214 elif headers and (auth_upstreams or service == "copyparty"):
215 native = service == "copyparty"
216 auth = f"127.0.0.1:{int(os.environ['STUDIO_DASHBOARD_PORT'])}" if native else " ".join(sorted(auth_upstreams))
217 if native:
218 lines += [" handle /auth/file/* {", " request_header -User-Name", " request_header -User-Groups",
219 f" request_header Studio-Proxy-Token {dashboard_proof}",
220 " request_header X-Studio-Client-IP {remote_host}", *proxy(auth," "), " }"]
221 else:
222 lines += [" handle /snow.oauth2/* {", *proxy(auth," "), " }"]
223 lines += [" handle {"]
209224 lines += [f" request_header -{name}" for name in scrub]
210 lines += [f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s", " method GET", " rewrite /snow.oauth2/auth",
225 lines += [f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s", " method GET", " rewrite " + ("/auth/file/check" if native else "/snow.oauth2/auth"),
226 *([f" header_up Studio-Proxy-Token {dashboard_proof}"] if native else []),
211227 " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}",
212228 " @authenticated status 2xx", " handle_response @authenticated {"]
213229 lines += [f" request_header {name} {{rp.header.{source}}}" for name, source in headers.items()]
......@@ -227,6 +243,11 @@ def render(token):
227243 *proxy(upstreams, " "), " }", "}"]
228244 else:
229245 lines += [f":{listener} {{", *proxy(" ".join(sorted(route["upstreams"])), " "), "}"]
246 if (80, auth_host) not in routes:
247 if not HOST.fullmatch(auth_host):
248 raise ValueError("invalid passkey domain")
249 lines += [f"{auth_host} {{", *([" tls internal"] if auth_host.endswith(".test") else []),
250 *webauthn, " handle {", " respond 503", " }", "}"]
230251 traces = next((route for route in routes.values() if "victoria-traces" in route["services"]), None)
231252 if traces:
232253 lines += ["http://127.0.0.1:10428 {", " bind 127.0.0.1",
......@@ -241,30 +262,10 @@ def render(token):
241262 if dashboard_host.endswith(".test"):
242263 lines.append(" tls internal")
243264 lines += [" encode zstd gzip", " tracing {", " span globe", " span_attributes {", " studio.kind edge", " }", " }"]
244 lines += [" @mcp_public path /oauth/* /mcp/* /.well-known/oauth-* /pairing /agent/connect /api/v1/*",
245 " handle @mcp_public {", " request_header -User-Name", " request_header -User-Groups",
246 " request_header -Studio-Proxy-Token", *proxy(f"127.0.0.1:{dashboard_port}", " "), " }"]
247 if auth_upstreams:
248 auth = " ".join(sorted(auth_upstreams))
249 lines += [
250 " handle /snow.oauth2/* {", *proxy(auth, " "), " }",
251 " handle {", " request_header -User-Name", " request_header -User-Groups", " request_header -Studio-Proxy-Token",
252 f" reverse_proxy {auth} {{", " lb_try_duration 5s", " fail_duration 30s",
253 " method GET", " rewrite /snow.oauth2/auth",
254 " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}",
255 " @unauthorized status 401", " handle_response @unauthorized {",
256 " redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri}", " }",
257 " @authenticated status 2xx", " handle_response @authenticated {",
258 " method {method}", " rewrite {uri}",
259 " request_header User-Name {rp.header.X-Auth-Request-Preferred-Username}",
260 " request_header User-Groups {rp.header.X-Auth-Request-Groups}",
261 f" request_header Studio-Proxy-Token {dashboard_proof}",
262 *proxy(f"127.0.0.1:{dashboard_port}", " "),
263 " }", " handle_response {", " respond 403", " }",
264 " }", " }", "}",
265 ]
266 else:
267 lines += [" header Retry-After 5", ' respond "Sign-in is unavailable. Try again in a moment." 503', "}"]
265 lines += [" handle {", " request_header -User-Name", " request_header -User-Groups",
266 f" request_header Studio-Proxy-Token {dashboard_proof}",
267 " request_header X-Studio-Client-IP {remote_host}",
268 *proxy(f"127.0.0.1:{dashboard_port}", " "), " }", "}"]
268269 internal_port = os.environ.get("STUDIO_INTERNAL_PORT")
269270 if internal_port is not None:
270271 internal_host = "dashboard.internal." + os.environ["STUDIO_DOMAIN"]