| author | |
| committer | |
| log | a7246389ae8115bab036e4edf5f5240d06901251 |
| tree | ebec2479e86771c559bfbd03ba70cb505e354be5 |
| parent | 2f9d63330af4cabbe5e6d66c465e4ee9fa529f5a |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-sol47 files changed, 3776 insertions(+), 809 deletions(-)
dashboard/.gitignore+1| ... | ... | @@ -3,3 +3,4 @@ dist/ |
| 3 | 3 | .cache/ |
| 4 | 4 | data/ |
| 5 | 5 | target/ |
| 6 | agent/relay.mjs |
dashboard/Cargo.lock+443-8| ... | ... | @@ -20,6 +20,57 @@ dependencies = [ |
| 20 | 20 | "libc", |
| 21 | 21 | ] |
| 22 | 22 | |
| 23 | [[package]] | |
| 24 | name = "argon2" | |
| 25 | version = "0.5.3" | |
| 26 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 27 | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 28 | dependencies = [ | |
| 29 | "base64ct", | |
| 30 | "blake2", | |
| 31 | "cpufeatures 0.2.17", | |
| 32 | "password-hash", | |
| 33 | ] | |
| 34 | ||
| 35 | [[package]] | |
| 36 | name = "asn1-rs" | |
| 37 | version = "0.6.2" | |
| 38 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" | |
| 40 | dependencies = [ | |
| 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]] | |
| 52 | name = "asn1-rs-derive" | |
| 53 | version = "0.5.1" | |
| 54 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 55 | checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" | |
| 56 | dependencies = [ | |
| 57 | "proc-macro2", | |
| 58 | "quote", | |
| 59 | "syn 2.0.119", | |
| 60 | "synstructure 0.13.2", | |
| 61 | ] | |
| 62 | ||
| 63 | [[package]] | |
| 64 | name = "asn1-rs-impl" | |
| 65 | version = "0.2.0" | |
| 66 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 67 | checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" | |
| 68 | dependencies = [ | |
| 69 | "proc-macro2", | |
| 70 | "quote", | |
| 71 | "syn 2.0.119", | |
| 72 | ] | |
| 73 | ||
| 23 | 74 | [[package]] |
| 24 | 75 | name = "async-trait" |
| 25 | 76 | version = "0.1.92" |
| ... | ... | @@ -99,6 +150,12 @@ dependencies = [ |
| 99 | 150 | "tracing", |
| 100 | 151 | ] |
| 101 | 152 | |
| 153 | [[package]] | |
| 154 | name = "base64" | |
| 155 | version = "0.21.7" | |
| 156 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 157 | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" | |
| 158 | ||
| 102 | 159 | [[package]] |
| 103 | 160 | name = "base64" |
| 104 | 161 | version = "0.22.1" |
| ... | ... | @@ -111,12 +168,38 @@ version = "0.23.1" |
| 111 | 168 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 112 | 169 | checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" |
| 113 | 170 | |
| 171 | [[package]] | |
| 172 | name = "base64ct" | |
| 173 | version = "1.8.3" | |
| 174 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 175 | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 176 | ||
| 177 | [[package]] | |
| 178 | name = "base64urlsafedata" | |
| 179 | version = "0.5.5" | |
| 180 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 181 | checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" | |
| 182 | dependencies = [ | |
| 183 | "base64 0.21.7", | |
| 184 | "pastey 0.1.1", | |
| 185 | "serde", | |
| 186 | ] | |
| 187 | ||
| 114 | 188 | [[package]] |
| 115 | 189 | name = "bitflags" |
| 116 | 190 | version = "2.13.2" |
| 117 | 191 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 118 | 192 | checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" |
| 119 | 193 | |
| 194 | [[package]] | |
| 195 | name = "blake2" | |
| 196 | version = "0.10.6" | |
| 197 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 198 | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 199 | dependencies = [ | |
| 200 | "digest", | |
| 201 | ] | |
| 202 | ||
| 120 | 203 | [[package]] |
| 121 | 204 | name = "block-buffer" |
| 122 | 205 | version = "0.10.4" |
| ... | ... | @@ -225,6 +308,12 @@ dependencies = [ |
| 225 | 308 | "libc", |
| 226 | 309 | ] |
| 227 | 310 | |
| 311 | [[package]] | |
| 312 | name = "crunchy" | |
| 313 | version = "0.2.4" | |
| 314 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 315 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 316 | ||
| 228 | 317 | [[package]] |
| 229 | 318 | name = "crypto-common" |
| 230 | 319 | version = "0.1.7" |
| ... | ... | @@ -264,6 +353,26 @@ version = "2.11.1" |
| 264 | 353 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 265 | 354 | checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" |
| 266 | 355 | |
| 356 | [[package]] | |
| 357 | name = "der-parser" | |
| 358 | version = "9.0.0" | |
| 359 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 360 | checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" | |
| 361 | dependencies = [ | |
| 362 | "asn1-rs", | |
| 363 | "displaydoc", | |
| 364 | "nom", | |
| 365 | "num-bigint", | |
| 366 | "num-traits", | |
| 367 | "rusticata-macros", | |
| 368 | ] | |
| 369 | ||
| 370 | [[package]] | |
| 371 | name = "deranged" | |
| 372 | version = "0.5.8" | |
| 373 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 374 | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" | |
| 375 | ||
| 267 | 376 | [[package]] |
| 268 | 377 | name = "derive_more" |
| 269 | 378 | version = "2.1.1" |
| ... | ... | @@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" |
| 293 | 402 | dependencies = [ |
| 294 | 403 | "block-buffer", |
| 295 | 404 | "crypto-common", |
| 405 | "subtle", | |
| 296 | 406 | ] |
| 297 | 407 | |
| 298 | 408 | [[package]] |
| ... | ... | @@ -393,6 +503,21 @@ version = "0.1.5" |
| 393 | 503 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 394 | 504 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" |
| 395 | 505 | |
| 506 | [[package]] | |
| 507 | name = "foreign-types" | |
| 508 | version = "0.3.2" | |
| 509 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 510 | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | |
| 511 | dependencies = [ | |
| 512 | "foreign-types-shared", | |
| 513 | ] | |
| 514 | ||
| 515 | [[package]] | |
| 516 | name = "foreign-types-shared" | |
| 517 | version = "0.1.1" | |
| 518 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 519 | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | |
| 520 | ||
| 396 | 521 | [[package]] |
| 397 | 522 | name = "form_urlencoded" |
| 398 | 523 | version = "1.2.2" |
| ... | ... | @@ -552,6 +677,17 @@ dependencies = [ |
| 552 | 677 | "regex-syntax", |
| 553 | 678 | ] |
| 554 | 679 | |
| 680 | [[package]] | |
| 681 | name = "half" | |
| 682 | version = "2.7.1" | |
| 683 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 684 | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | |
| 685 | dependencies = [ | |
| 686 | "cfg-if", | |
| 687 | "crunchy", | |
| 688 | "zerocopy", | |
| 689 | ] | |
| 690 | ||
| 555 | 691 | [[package]] |
| 556 | 692 | name = "hashbrown" |
| 557 | 693 | version = "0.15.5" |
| ... | ... | @@ -576,10 +712,17 @@ dependencies = [ |
| 576 | 712 | "hashbrown 0.15.5", |
| 577 | 713 | ] |
| 578 | 714 | |
| 715 | [[package]] | |
| 716 | name = "hex" | |
| 717 | version = "0.4.3" | |
| 718 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 719 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" | |
| 720 | ||
| 579 | 721 | [[package]] |
| 580 | 722 | name = "home-dashboard" |
| 581 | 723 | version = "0.1.0" |
| 582 | 724 | dependencies = [ |
| 725 | "argon2", | |
| 583 | 726 | "axum", |
| 584 | 727 | "base64 0.22.1", |
| 585 | 728 | "bytes", |
| ... | ... | @@ -592,6 +735,8 @@ dependencies = [ |
| 592 | 735 | "rmcp", |
| 593 | 736 | "rusqlite", |
| 594 | 737 | "scraper", |
| 738 | "serde", | |
| 739 | "serde_cbor_2", | |
| 595 | 740 | "serde_json", |
| 596 | 741 | "sha1", |
| 597 | 742 | "sha2", |
| ... | ... | @@ -602,6 +747,7 @@ dependencies = [ |
| 602 | 747 | "url", |
| 603 | 748 | "uuid", |
| 604 | 749 | "walkdir", |
| 750 | "webauthn-rs", | |
| 605 | 751 | ] |
| 606 | 752 | |
| 607 | 753 | [[package]] |
| ... | ... | @@ -889,6 +1035,12 @@ dependencies = [ |
| 889 | 1035 | "wasm-bindgen", |
| 890 | 1036 | ] |
| 891 | 1037 | |
| 1038 | [[package]] | |
| 1039 | name = "lazy_static" | |
| 1040 | version = "1.5.1" | |
| 1041 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1042 | checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" | |
| 1043 | ||
| 892 | 1044 | [[package]] |
| 893 | 1045 | name = "libc" |
| 894 | 1046 | version = "0.2.189" |
| ... | ... | @@ -972,6 +1124,12 @@ dependencies = [ |
| 972 | 1124 | "unicase", |
| 973 | 1125 | ] |
| 974 | 1126 | |
| 1127 | [[package]] | |
| 1128 | name = "minimal-lexical" | |
| 1129 | version = "0.2.1" | |
| 1130 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1131 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" | |
| 1132 | ||
| 975 | 1133 | [[package]] |
| 976 | 1134 | name = "mio" |
| 977 | 1135 | version = "1.2.3" |
| ... | ... | @@ -1012,6 +1170,41 @@ version = "1.0.6" |
| 1012 | 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1013 | 1171 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" |
| 1014 | 1172 | |
| 1173 | [[package]] | |
| 1174 | name = "nom" | |
| 1175 | version = "7.1.3" | |
| 1176 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1177 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" | |
| 1178 | dependencies = [ | |
| 1179 | "memchr", | |
| 1180 | "minimal-lexical", | |
| 1181 | ] | |
| 1182 | ||
| 1183 | [[package]] | |
| 1184 | name = "num-bigint" | |
| 1185 | version = "0.4.8" | |
| 1186 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1187 | checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" | |
| 1188 | dependencies = [ | |
| 1189 | "num-integer", | |
| 1190 | "num-traits", | |
| 1191 | ] | |
| 1192 | ||
| 1193 | [[package]] | |
| 1194 | name = "num-conv" | |
| 1195 | version = "0.2.2" | |
| 1196 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1197 | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 1198 | ||
| 1199 | [[package]] | |
| 1200 | name = "num-integer" | |
| 1201 | version = "0.1.47" | |
| 1202 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1203 | checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" | |
| 1204 | dependencies = [ | |
| 1205 | "num-traits", | |
| 1206 | ] | |
| 1207 | ||
| 1015 | 1208 | [[package]] |
| 1016 | 1209 | name = "num-traits" |
| 1017 | 1210 | version = "0.2.19" |
| ... | ... | @@ -1021,12 +1214,58 @@ dependencies = [ |
| 1021 | 1214 | "autocfg", |
| 1022 | 1215 | ] |
| 1023 | 1216 | |
| 1217 | [[package]] | |
| 1218 | name = "oid-registry" | |
| 1219 | version = "0.7.1" | |
| 1220 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1221 | checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" | |
| 1222 | dependencies = [ | |
| 1223 | "asn1-rs", | |
| 1224 | ] | |
| 1225 | ||
| 1024 | 1226 | [[package]] |
| 1025 | 1227 | name = "once_cell" |
| 1026 | 1228 | version = "1.21.4" |
| 1027 | 1229 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1028 | 1230 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
| 1029 | 1231 | |
| 1232 | [[package]] | |
| 1233 | name = "openssl" | |
| 1234 | version = "0.10.81" | |
| 1235 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1236 | checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" | |
| 1237 | dependencies = [ | |
| 1238 | "bitflags", | |
| 1239 | "cfg-if", | |
| 1240 | "foreign-types", | |
| 1241 | "libc", | |
| 1242 | "openssl-macros", | |
| 1243 | "openssl-sys", | |
| 1244 | ] | |
| 1245 | ||
| 1246 | [[package]] | |
| 1247 | name = "openssl-macros" | |
| 1248 | version = "0.1.1" | |
| 1249 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1250 | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" | |
| 1251 | dependencies = [ | |
| 1252 | "proc-macro2", | |
| 1253 | "quote", | |
| 1254 | "syn 2.0.119", | |
| 1255 | ] | |
| 1256 | ||
| 1257 | [[package]] | |
| 1258 | name = "openssl-sys" | |
| 1259 | version = "0.9.117" | |
| 1260 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1261 | checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" | |
| 1262 | dependencies = [ | |
| 1263 | "cc", | |
| 1264 | "libc", | |
| 1265 | "pkg-config", | |
| 1266 | "vcpkg", | |
| 1267 | ] | |
| 1268 | ||
| 1030 | 1269 | [[package]] |
| 1031 | 1270 | name = "parking_lot" |
| 1032 | 1271 | version = "0.12.5" |
| ... | ... | @@ -1050,6 +1289,23 @@ dependencies = [ |
| 1050 | 1289 | "windows-link", |
| 1051 | 1290 | ] |
| 1052 | 1291 | |
| 1292 | [[package]] | |
| 1293 | name = "password-hash" | |
| 1294 | version = "0.5.0" | |
| 1295 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1296 | checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" | |
| 1297 | dependencies = [ | |
| 1298 | "base64ct", | |
| 1299 | "rand_core 0.6.4", | |
| 1300 | "subtle", | |
| 1301 | ] | |
| 1302 | ||
| 1303 | [[package]] | |
| 1304 | name = "pastey" | |
| 1305 | version = "0.1.1" | |
| 1306 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1307 | checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" | |
| 1308 | ||
| 1053 | 1309 | [[package]] |
| 1054 | 1310 | name = "pastey" |
| 1055 | 1311 | version = "0.2.3" |
| ... | ... | @@ -1136,6 +1392,12 @@ dependencies = [ |
| 1136 | 1392 | "zerovec", |
| 1137 | 1393 | ] |
| 1138 | 1394 | |
| 1395 | [[package]] | |
| 1396 | name = "powerfmt" | |
| 1397 | version = "0.2.0" | |
| 1398 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1399 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" | |
| 1400 | ||
| 1139 | 1401 | [[package]] |
| 1140 | 1402 | name = "ppv-lite86" |
| 1141 | 1403 | version = "0.2.21" |
| ... | ... | @@ -1174,7 +1436,7 @@ dependencies = [ |
| 1174 | 1436 | "rustc-hash", |
| 1175 | 1437 | "rustls", |
| 1176 | 1438 | "socket2", |
| 1177 | "thiserror", | |
| 1439 | "thiserror 2.0.21", | |
| 1178 | 1440 | "tokio", |
| 1179 | 1441 | "tracing", |
| 1180 | 1442 | "web-time", |
| ... | ... | @@ -1196,7 +1458,7 @@ dependencies = [ |
| 1196 | 1458 | "rustls", |
| 1197 | 1459 | "rustls-pki-types", |
| 1198 | 1460 | "slab", |
| 1199 | "thiserror", | |
| 1461 | "thiserror 2.0.21", | |
| 1200 | 1462 | "tinyvec", |
| 1201 | 1463 | "tracing", |
| 1202 | 1464 | "web-time", |
| ... | ... | @@ -1268,6 +1530,12 @@ dependencies = [ |
| 1268 | 1530 | "rand_core 0.9.5", |
| 1269 | 1531 | ] |
| 1270 | 1532 | |
| 1533 | [[package]] | |
| 1534 | name = "rand_core" | |
| 1535 | version = "0.6.4" | |
| 1536 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1537 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" | |
| 1538 | ||
| 1271 | 1539 | [[package]] |
| 1272 | 1540 | name = "rand_core" |
| 1273 | 1541 | version = "0.9.5" |
| ... | ... | @@ -1419,14 +1687,14 @@ dependencies = [ |
| 1419 | 1687 | "http-body", |
| 1420 | 1688 | "http-body-util", |
| 1421 | 1689 | "indexmap", |
| 1422 | "pastey", | |
| 1690 | "pastey 0.2.3", | |
| 1423 | 1691 | "pin-project-lite", |
| 1424 | 1692 | "rand 0.10.3", |
| 1425 | 1693 | "schemars", |
| 1426 | 1694 | "serde", |
| 1427 | 1695 | "serde_json", |
| 1428 | 1696 | "sse-stream", |
| 1429 | "thiserror", | |
| 1697 | "thiserror 2.0.21", | |
| 1430 | 1698 | "tokio", |
| 1431 | 1699 | "tokio-stream", |
| 1432 | 1700 | "tokio-util", |
| ... | ... | @@ -1464,6 +1732,15 @@ dependencies = [ |
| 1464 | 1732 | "semver", |
| 1465 | 1733 | ] |
| 1466 | 1734 | |
| 1735 | [[package]] | |
| 1736 | name = "rusticata-macros" | |
| 1737 | version = "4.1.0" | |
| 1738 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1739 | checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" | |
| 1740 | dependencies = [ | |
| 1741 | "nom", | |
| 1742 | ] | |
| 1743 | ||
| 1467 | 1744 | [[package]] |
| 1468 | 1745 | name = "rustls" |
| 1469 | 1746 | version = "0.23.45" |
| ... | ... | @@ -1601,6 +1878,16 @@ dependencies = [ |
| 1601 | 1878 | "serde_derive", |
| 1602 | 1879 | ] |
| 1603 | 1880 | |
| 1881 | [[package]] | |
| 1882 | name = "serde_cbor_2" | |
| 1883 | version = "0.13.0" | |
| 1884 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1885 | checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" | |
| 1886 | dependencies = [ | |
| 1887 | "half", | |
| 1888 | "serde", | |
| 1889 | ] | |
| 1890 | ||
| 1604 | 1891 | [[package]] |
| 1605 | 1892 | name = "serde_core" |
| 1606 | 1893 | version = "1.0.229" |
| ... | ... | @@ -1835,6 +2122,17 @@ dependencies = [ |
| 1835 | 2122 | "futures-core", |
| 1836 | 2123 | ] |
| 1837 | 2124 | |
| 2125 | [[package]] | |
| 2126 | name = "synstructure" | |
| 2127 | version = "0.13.2" | |
| 2128 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2129 | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" | |
| 2130 | dependencies = [ | |
| 2131 | "proc-macro2", | |
| 2132 | "quote", | |
| 2133 | "syn 2.0.119", | |
| 2134 | ] | |
| 2135 | ||
| 1838 | 2136 | [[package]] |
| 1839 | 2137 | name = "synstructure" |
| 1840 | 2138 | version = "0.14.0" |
| ... | ... | @@ -1855,13 +2153,33 @@ dependencies = [ |
| 1855 | 2153 | "new_debug_unreachable", |
| 1856 | 2154 | ] |
| 1857 | 2155 | |
| 2156 | [[package]] | |
| 2157 | name = "thiserror" | |
| 2158 | version = "1.0.69" | |
| 2159 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2160 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" | |
| 2161 | dependencies = [ | |
| 2162 | "thiserror-impl 1.0.69", | |
| 2163 | ] | |
| 2164 | ||
| 1858 | 2165 | [[package]] |
| 1859 | 2166 | name = "thiserror" |
| 1860 | 2167 | version = "2.0.21" |
| 1861 | 2168 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1862 | 2169 | checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" |
| 1863 | 2170 | dependencies = [ |
| 1864 | "thiserror-impl", | |
| 2171 | "thiserror-impl 2.0.21", | |
| 2172 | ] | |
| 2173 | ||
| 2174 | [[package]] | |
| 2175 | name = "thiserror-impl" | |
| 2176 | version = "1.0.69" | |
| 2177 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2178 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" | |
| 2179 | dependencies = [ | |
| 2180 | "proc-macro2", | |
| 2181 | "quote", | |
| 2182 | "syn 2.0.119", | |
| 1865 | 2183 | ] |
| 1866 | 2184 | |
| 1867 | 2185 | [[package]] |
| ... | ... | @@ -1875,6 +2193,36 @@ dependencies = [ |
| 1875 | 2193 | "syn 3.0.6", |
| 1876 | 2194 | ] |
| 1877 | 2195 | |
| 2196 | [[package]] | |
| 2197 | name = "time" | |
| 2198 | version = "0.3.55" | |
| 2199 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2200 | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" | |
| 2201 | dependencies = [ | |
| 2202 | "deranged", | |
| 2203 | "num-conv", | |
| 2204 | "powerfmt", | |
| 2205 | "serde_core", | |
| 2206 | "time-core", | |
| 2207 | "time-macros", | |
| 2208 | ] | |
| 2209 | ||
| 2210 | [[package]] | |
| 2211 | name = "time-core" | |
| 2212 | version = "0.1.9" | |
| 2213 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2214 | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 2215 | ||
| 2216 | [[package]] | |
| 2217 | name = "time-macros" | |
| 2218 | version = "0.2.32" | |
| 2219 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2220 | checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" | |
| 2221 | dependencies = [ | |
| 2222 | "num-conv", | |
| 2223 | "time-core", | |
| 2224 | ] | |
| 2225 | ||
| 1878 | 2226 | [[package]] |
| 1879 | 2227 | name = "tinystr" |
| 1880 | 2228 | version = "0.8.4" |
| ... | ... | @@ -2073,7 +2421,7 @@ dependencies = [ |
| 2073 | 2421 | "log", |
| 2074 | 2422 | "rand 0.9.5", |
| 2075 | 2423 | "sha1", |
| 2076 | "thiserror", | |
| 2424 | "thiserror 2.0.21", | |
| 2077 | 2425 | ] |
| 2078 | 2426 | |
| 2079 | 2427 | [[package]] |
| ... | ... | @@ -2110,6 +2458,7 @@ dependencies = [ |
| 2110 | 2458 | "idna", |
| 2111 | 2459 | "percent-encoding", |
| 2112 | 2460 | "serde", |
| 2461 | "serde_derive", | |
| 2113 | 2462 | ] |
| 2114 | 2463 | |
| 2115 | 2464 | [[package]] |
| ... | ... | @@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" |
| 2126 | 2475 | dependencies = [ |
| 2127 | 2476 | "getrandom 0.4.3", |
| 2128 | 2477 | "js-sys", |
| 2478 | "serde_core", | |
| 2129 | 2479 | "wasm-bindgen", |
| 2130 | 2480 | ] |
| 2131 | 2481 | |
| ... | ... | @@ -2263,6 +2613,74 @@ dependencies = [ |
| 2263 | 2613 | "string_cache_codegen", |
| 2264 | 2614 | ] |
| 2265 | 2615 | |
| 2616 | [[package]] | |
| 2617 | name = "webauthn-attestation-ca" | |
| 2618 | version = "0.5.5" | |
| 2619 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2620 | checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" | |
| 2621 | dependencies = [ | |
| 2622 | "base64urlsafedata", | |
| 2623 | "openssl", | |
| 2624 | "openssl-sys", | |
| 2625 | "serde", | |
| 2626 | "tracing", | |
| 2627 | "uuid", | |
| 2628 | ] | |
| 2629 | ||
| 2630 | [[package]] | |
| 2631 | name = "webauthn-rs" | |
| 2632 | version = "0.5.5" | |
| 2633 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2634 | checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" | |
| 2635 | dependencies = [ | |
| 2636 | "base64urlsafedata", | |
| 2637 | "serde", | |
| 2638 | "tracing", | |
| 2639 | "url", | |
| 2640 | "uuid", | |
| 2641 | "webauthn-rs-core", | |
| 2642 | ] | |
| 2643 | ||
| 2644 | [[package]] | |
| 2645 | name = "webauthn-rs-core" | |
| 2646 | version = "0.5.5" | |
| 2647 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2648 | checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" | |
| 2649 | dependencies = [ | |
| 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]] | |
| 2672 | name = "webauthn-rs-proto" | |
| 2673 | version = "0.5.5" | |
| 2674 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2675 | checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" | |
| 2676 | dependencies = [ | |
| 2677 | "base64 0.21.7", | |
| 2678 | "base64urlsafedata", | |
| 2679 | "serde", | |
| 2680 | "serde_json", | |
| 2681 | "url", | |
| 2682 | ] | |
| 2683 | ||
| 2266 | 2684 | [[package]] |
| 2267 | 2685 | name = "webpki-roots" |
| 2268 | 2686 | version = "1.0.9" |
| ... | ... | @@ -2434,6 +2852,23 @@ version = "0.6.4" |
| 2434 | 2852 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2435 | 2853 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" |
| 2436 | 2854 | |
| 2855 | [[package]] | |
| 2856 | name = "x509-parser" | |
| 2857 | version = "0.16.0" | |
| 2858 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2859 | checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" | |
| 2860 | dependencies = [ | |
| 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 | ||
| 2437 | 2872 | [[package]] |
| 2438 | 2873 | name = "yoke" |
| 2439 | 2874 | version = "0.8.3" |
| ... | ... | @@ -2454,7 +2889,7 @@ dependencies = [ |
| 2454 | 2889 | "proc-macro2", |
| 2455 | 2890 | "quote", |
| 2456 | 2891 | "syn 3.0.6", |
| 2457 | "synstructure", | |
| 2892 | "synstructure 0.14.0", | |
| 2458 | 2893 | ] |
| 2459 | 2894 | |
| 2460 | 2895 | [[package]] |
| ... | ... | @@ -2495,7 +2930,7 @@ dependencies = [ |
| 2495 | 2930 | "proc-macro2", |
| 2496 | 2931 | "quote", |
| 2497 | 2932 | "syn 3.0.6", |
| 2498 | "synstructure", | |
| 2933 | "synstructure 0.14.0", | |
| 2499 | 2934 | ] |
| 2500 | 2935 | |
| 2501 | 2936 | [[package]] |
dashboard/Cargo.toml+4| ... | ... | @@ -4,6 +4,7 @@ version = "0.1.0" |
| 4 | 4 | edition = "2024" |
| 5 | 5 | |
| 6 | 6 | [dependencies] |
| 7 | argon2 = "0.5" | |
| 7 | 8 | axum = { version = "0.8.9", features = ["multipart", "ws"] } |
| 8 | 9 | base64 = "0.22" |
| 9 | 10 | bytes = "1" |
| ... | ... | @@ -15,6 +16,8 @@ regex = "1" |
| 15 | 16 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } |
| 16 | 17 | rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } |
| 17 | 18 | rusqlite = { version = "0.37", features = ["bundled"] } |
| 19 | serde = { version = "1", features = ["derive"] } | |
| 20 | serde_cbor_2 = "0.13" | |
| 18 | 21 | scraper = { version = "0.27", default-features = false } |
| 19 | 22 | serde_json = "1" |
| 20 | 23 | sha1 = "0.10" |
| ... | ... | @@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] } |
| 26 | 29 | url = "2" |
| 27 | 30 | uuid = { version = "1", features = ["v4"] } |
| 28 | 31 | walkdir = "2" |
| 32 | webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } | |
| 29 | 33 | |
| 30 | 34 | [profile.release] |
| 31 | 35 | lto = "thin" |
dashboard/agent/install.ps1 created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | $ErrorActionPreference = 'Stop' | |
| 2 | $Server = __SERVER__ | |
| 3 | ||
| 4 | function 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 | ||
| 41 | Install-Agent |
dashboard/agent/install.sh created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | #!/bin/sh | |
| 2 | set -eu | |
| 3 | umask 077 | |
| 4 | server=__SERVER__ | |
| 5 | ||
| 6 | install_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. | |
| 53 | install_agent |
dashboard/agent/setup.mjs created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | import { execFileSync, spawn, spawnSync } from 'node:child_process'; | |
| 2 | import { copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; | |
| 3 | import { homedir, hostname } from 'node:os'; | |
| 4 | import { delimiter, dirname, join, resolve } from 'node:path'; | |
| 5 | import { createInterface } from 'node:readline/promises'; | |
| 6 | import { setTimeout as sleep } from 'node:timers/promises'; | |
| 7 | ||
| 8 | const [action = 'status', server, installDir] = process.argv.slice(2); | |
| 9 | const base = installDir ?? dirname(process.argv[1]); | |
| 10 | const windows = process.platform === 'win32'; | |
| 11 | const mac = process.platform === 'darwin'; | |
| 12 | const data = windows ? join(base, 'config') : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'agent-relay'); | |
| 13 | const 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'); | |
| 15 | const task = windows ? 'AgentRelay-' + execFileSync('whoami.exe', ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf8' }).match(/S-1-5-[\d-]+/)[0] : ''; | |
| 16 | const domain = mac ? `gui/${process.getuid()}` : ''; | |
| 17 | const ps = (script) => execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'inherit' }); | |
| 18 | const psQuote = (text) => "'" + text.replaceAll("'", "''") + "'"; | |
| 19 | const shellQuote = (text) => "'" + text.replaceAll("'", "'\\''") + "'"; | |
| 20 | ||
| 21 | async function stop() { | |
| 22 | if (windows) ps(`$ErrorActionPreference = 'Stop' | |
| 23 | if (Get-ScheduledTask -TaskName ${psQuote(task)} -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName ${psQuote(task)} } | |
| 24 | Get-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 | ||
| 45 | async 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 | |
| 158 | Register-ScheduledTask -TaskName ${psQuote(task)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null | |
| 159 | Start-ScheduledTask -TaskName ${psQuote(task)} | |
| 160 | if ((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 | ||
| 192 | main().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 @@ |
| 15 | 15 | "@solidjs/router": "^1.0.0", |
| 16 | 16 | "@types/node": "^26.6.2", |
| 17 | 17 | "concurrently": "^10.0.5", |
| 18 | "esbuild": "0.28.2", | |
| 18 | 19 | "lucide-solid": "^1.48.0", |
| 19 | 20 | "solid-js": "^1.9.15", |
| 20 | 21 | "typescript": "^7.0.2", |
dashboard/pnpm-lock.yaml+3-1| ... | ... | @@ -21,6 +21,9 @@ importers: |
| 21 | 21 | concurrently: |
| 22 | 22 | specifier: ^10.0.5 |
| 23 | 23 | version: 10.0.5 |
| 24 | esbuild: | |
| 25 | specifier: 0.28.2 | |
| 26 | version: 0.28.2 | |
| 24 | 27 | lucide-solid: |
| 25 | 28 | specifier: ^1.48.0 |
| 26 | 29 | version: 1.48.0(solid-js@1.9.15) |
| ... | ... | @@ -1379,7 +1382,6 @@ snapshots: |
| 1379 | 1382 | '@esbuild/win32-arm64': 0.28.2 |
| 1380 | 1383 | '@esbuild/win32-ia32': 0.28.2 |
| 1381 | 1384 | '@esbuild/win32-x64': 0.28.2 |
| 1382 | optional: true | |
| 1383 | 1385 | |
| 1384 | 1386 | escalade@3.2.0: {} |
| 1385 | 1387 |
dashboard/src/auth.rs created+1114| ... | ... | @@ -0,0 +1,1114 @@ |
| 1 | use crate::*; | |
| 2 | use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; | |
| 3 | use base64::{ | |
| 4 | Engine, | |
| 5 | engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}, | |
| 6 | }; | |
| 7 | use rusqlite::{Connection, OptionalExtension, params as sql}; | |
| 8 | use std::os::unix::fs::PermissionsExt; | |
| 9 | use webauthn_rs::prelude::*; | |
| 10 | ||
| 11 | const COOKIE: &str = "__Host-snow-session"; | |
| 12 | const FLOW_COOKIE: &str = "__Host-snow-flow"; | |
| 13 | const SESSION_TTL: i64 = 30 * 86400; | |
| 14 | const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm"]; | |
| 15 | ||
| 16 | pub struct Store { | |
| 17 | pub db: Mutex<Connection>, | |
| 18 | pub origin: url::Url, | |
| 19 | file: url::Url, | |
| 20 | webauthn: Webauthn, | |
| 21 | passwords: Semaphore, | |
| 22 | } | |
| 23 | ||
| 24 | pub 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 | } | |
| 35 | fn set_cookie(name: &str, value: &str, ttl: i64) -> String { | |
| 36 | format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}") | |
| 37 | } | |
| 38 | fn 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 | } | |
| 45 | fn 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 | } | |
| 61 | fn 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 | } | |
| 82 | pub 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 | } | |
| 101 | pub 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 | } | |
| 107 | pub 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 | } | |
| 125 | pub 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 | } | |
| 133 | pub 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 | ||
| 152 | impl 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 | ||
| 547 | pub 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 | ||
| 1104 | fn 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 { |
| 51 | 51 | Ok(entry) |
| 52 | 52 | } |
| 53 | 53 | |
| 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 | ||
| 82 | 54 | pub fn invalidate(&self, key: &str) { |
| 83 | 55 | self.0.lock().unwrap().remove(key); |
| 84 | 56 | } |
| ... | ... | @@ -180,51 +152,6 @@ mod tests { |
| 180 | 152 | use super::*; |
| 181 | 153 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 182 | 154 | #[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] | |
| 228 | 155 | async fn disconnecting_reader_does_not_cancel_shared_load() { |
| 229 | 156 | let cache = Arc::new(Cache::default()); |
| 230 | 157 | let started = Arc::new(tokio::sync::Notify::new()); |
dashboard/src/core.rs+7-2| ... | ... | @@ -839,7 +839,9 @@ mod tests { |
| 839 | 839 | #[tokio::test] |
| 840 | 840 | async fn launcher_excludes_directories_and_grouped_definitions_before_import() { |
| 841 | 841 | 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(); | |
| 843 | 845 | for directory in ["retired", "personal"] { |
| 844 | 846 | tokio::fs::create_dir_all(root.join("service").join(directory)) |
| 845 | 847 | .await |
| ... | ... | @@ -864,7 +866,10 @@ mod tests { |
| 864 | 866 | assert!(module.contains("/personal/service.pkl")); |
| 865 | 867 | assert!(module.contains("/personal/fixture.pkl")); |
| 866 | 868 | 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(), | |
| 868 | 873 | 2, |
| 869 | 874 | ); |
| 870 | 875 | tokio::fs::remove_dir_all(root).await.unwrap(); |
dashboard/src/main.rs+113-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | mod apps; |
| 2 | mod auth; | |
| 2 | 3 | mod cache; |
| 3 | 4 | mod core; |
| 4 | 5 | mod deploys; |
| ... | ... | @@ -82,6 +83,7 @@ impl Document { |
| 82 | 83 | } |
| 83 | 84 | |
| 84 | 85 | struct App { |
| 86 | auth: auth::Store, | |
| 85 | 87 | mcp: mcp::Store, |
| 86 | 88 | relay: relay::Broker, |
| 87 | 89 | shale: shale::Backend, |
| ... | ... | @@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 388 | 390 | "STUDIO_PUBLIC_ORIGIN", |
| 389 | 391 | &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")), |
| 390 | 392 | ); |
| 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 | } | |
| 391 | 431 | let app = Arc::new(App { |
| 432 | auth, | |
| 392 | 433 | mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin) |
| 393 | 434 | .map_err(|error| std::io::Error::other(error.message))?, |
| 394 | 435 | relay: relay::Broker::default(), |
| ... | ... | @@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 437 | 478 | let dist = env("STUDIO_WEB_DIR", "dist"); |
| 438 | 479 | let router = Router::new() |
| 439 | 480 | .route("/api/{*path}", any(api)) |
| 481 | .route("/auth/{*path}", any(auth::route)) | |
| 440 | 482 | .route("/oauth/{*path}", any(mcp::oauth)) |
| 441 | 483 | .route("/.well-known/{*path}", any(mcp::oauth)) |
| 442 | 484 | .nest_service("/assets", ServeDir::new(format!("{dist}/assets"))) |
| 443 | 485 | .with_state(app.clone()) |
| 444 | 486 | .merge(observability::router(app.clone())) |
| 445 | 487 | .merge(shale::router(app.clone())) |
| 446 | .merge(relay::router(app)) | |
| 488 | .merge(relay::router(app.clone())) | |
| 447 | 489 | .fallback_service( |
| 448 | 490 | ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))), |
| 449 | 491 | ) |
| 450 | 492 | .layer(axum::middleware::from_fn( |
| 451 | 493 | move |mut request: Request, next: axum::middleware::Next| { |
| 452 | 494 | let proof = proof.clone(); |
| 495 | let app = app.clone(); | |
| 453 | 496 | async move { |
| 454 | 497 | if mcp::public(request.uri().path()) { |
| 455 | 498 | request.headers_mut().remove("Studio-Proxy-Token"); |
| ... | ... | @@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 467 | 510 | } |
| 468 | 511 | request.headers_mut().remove("Studio-Proxy-Token"); |
| 469 | 512 | } |
| 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 | } | |
| 471 | 579 | let document = !asset && !request.uri().path().starts_with("/api/"); |
| 472 | 580 | if document { |
| 473 | 581 | request.headers_mut().remove("if-modified-since"); |
| 474 | 582 | request.headers_mut().remove("if-none-match"); |
| 475 | 583 | } |
| 476 | 584 | 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()); | |
| 477 | 588 | if asset && response.status().is_success() { |
| 478 | 589 | response.headers_mut().insert( |
| 479 | 590 | "cache-control", |
dashboard/src/mcp.rs+143-47| ... | ... | @@ -445,7 +445,7 @@ impl Store { |
| 445 | 445 | tx.commit()?; |
| 446 | 446 | return Ok(( |
| 447 | 447 | StatusCode::FOUND, |
| 448 | [("location", format!("/mcp?request={}", encoded(&pending)))], | |
| 448 | [("location", format!("/connect/{}", encoded(&pending)))], | |
| 449 | 449 | ) |
| 450 | 450 | .into_response()); |
| 451 | 451 | } |
| ... | ... | @@ -490,6 +490,16 @@ impl Store { |
| 490 | 490 | |
| 491 | 491 | #[derive(Clone)] |
| 492 | 492 | pub(crate) struct Grant(pub Value); |
| 493 | pub(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 | } | |
| 493 | 503 | pub fn router<H: rmcp::ServerHandler>( |
| 494 | 504 | app: Arc<App>, |
| 495 | 505 | catalog: &str, |
| ... | ... | @@ -529,7 +539,9 @@ pub fn router<H: rmcp::ServerHandler>( |
| 529 | 539 | return Error::new(403, "This origin cannot use the connector.") |
| 530 | 540 | .into_response(); |
| 531 | 541 | } |
| 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 | }) { | |
| 533 | 545 | Ok(grant) => { |
| 534 | 546 | request.extensions_mut().insert(Grant(grant)); |
| 535 | 547 | if let Some(value) = request.headers_mut().get_mut("authorization") { |
| ... | ... | @@ -554,10 +566,16 @@ pub fn router<H: rmcp::ServerHandler>( |
| 554 | 566 | |
| 555 | 567 | pub fn public(path: &str) -> bool { |
| 556 | 568 | 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 | }) | |
| 558 | 572 | || path.starts_with("/.well-known/oauth-") |
| 559 | 573 | || path == "/pairing" |
| 560 | 574 | || path == "/agent/connect" |
| 575 | || matches!( | |
| 576 | path, | |
| 577 | "/agent/install.sh" | "/agent/install.ps1" | "/agent/setup.mjs" | "/agent/relay.mjs" | |
| 578 | ) | |
| 561 | 579 | || path.starts_with("/api/v1/") |
| 562 | 580 | } |
| 563 | 581 | pub 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 { |
| 590 | 608 | let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null}; |
| 591 | 609 | if path == "/oauth/token" && method == Method::POST { |
| 592 | 610 | 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)? { | |
| 606 | 612 | revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; |
| 607 | 613 | return Err(fail("invalid_grant")); |
| 608 | 614 | } |
| ... | ... | @@ -633,6 +639,25 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response { |
| 633 | 639 | response |
| 634 | 640 | } |
| 635 | 641 | |
| 642 | fn 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 | ||
| 636 | 661 | pub async fn manage( |
| 637 | 662 | app: Arc<App>, |
| 638 | 663 | method: &Method, |
| ... | ... | @@ -672,32 +697,66 @@ pub async fn manage( |
| 672 | 697 | .keep_alive(axum::response::sse::KeepAlive::default()) |
| 673 | 698 | .into_response()); |
| 674 | 699 | } |
| 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( | |
| 677 | 710 | &app.mcp.db.lock().unwrap(), |
| 678 | 711 | &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() | |
| 683 | 726 | } else { |
| 684 | 727 | String::new() |
| 685 | 728 | }; |
| 686 | 729 | let agent_consent = consent_resource == app.mcp.resource("agents"); |
| 687 | 730 | 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); | |
| 688 | 735 | 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 { | |
| 690 | 740 | relay::machines(&app.mcp.db.lock().unwrap(), owner_id)? |
| 691 | 741 | .into_iter() |
| 692 | 742 | .map(|m| json!({"id":m["id"],"name":m["name"]})) |
| 693 | 743 | .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 { | |
| 696 | 751 | Ok(repositories) => repositories, |
| 697 | 752 | Err(error) if error.status == 401 => { |
| 698 | 753 | linked = false; |
| 699 | 754 | Vec::new() |
| 700 | 755 | } |
| 756 | Err(error) if method == Method::GET => { | |
| 757 | resource_error = Some(error.message); | |
| 758 | Vec::new() | |
| 759 | } | |
| 701 | 760 | Err(error) => return Err(error), |
| 702 | 761 | } |
| 703 | 762 | } else if consent_resource == app.mcp.resource("observability") |
| ... | ... | @@ -726,13 +785,14 @@ pub async fn manage( |
| 726 | 785 | let machines = relay::machines(&tx, owner_id)?; |
| 727 | 786 | let connections = grants.iter().map(|grant| { |
| 728 | 787 | 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"]})) | |
| 731 | 791 | }).collect::<Result<Vec<_>>>()?; |
| 732 | 792 | let shale = get(&tx, &format!("shale-session:{owner_id}"))?; |
| 733 | 793 | let catalogs: Vec<_> = CATALOGS |
| 734 | 794 | .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)})) | |
| 736 | 796 | .collect(); |
| 737 | 797 | 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"]})}}) |
| 738 | 798 | } |
| ... | ... | @@ -770,29 +830,19 @@ pub async fn manage( |
| 770 | 830 | "UPDATE records SET value=? WHERE key=?", |
| 771 | 831 | rusqlite::params![pending.to_string(), key], |
| 772 | 832 | )?; |
| 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}) | |
| 774 | 834 | } else { |
| 775 | 835 | 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()) | |
| 778 | 839 | { |
| 779 | 840 | return Err(Error::new( |
| 780 | 841 | 401, |
| 781 | 842 | "Link your Shale account before allowing repository access.", |
| 782 | 843 | )); |
| 783 | 844 | } |
| 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)?; | |
| 796 | 846 | if list(&tx, "grant:")? |
| 797 | 847 | .iter() |
| 798 | 848 | .filter(|g| g["user"] == owner_id) |
| ... | ... | @@ -826,13 +876,37 @@ pub async fn manage( |
| 826 | 876 | } |
| 827 | 877 | } |
| 828 | 878 | ["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)?; | |
| 831 | 884 | if grant["user"] != owner_id { |
| 832 | 885 | return Err(Error::new(404, "No connection with that ID.")); |
| 833 | 886 | } |
| 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 | } | |
| 836 | 910 | } |
| 837 | 911 | _ => return Err(Error::new(404, "No endpoint here.")), |
| 838 | 912 | }; |
| ... | ... | @@ -849,6 +923,28 @@ pub async fn manage( |
| 849 | 923 | |
| 850 | 924 | #[cfg(test)] |
| 851 | 925 | mod 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 | ||
| 852 | 948 | use super::*; |
| 853 | 949 | struct Fixture { |
| 854 | 950 | store: Arc<Store>, |
dashboard/src/relay.rs+27| ... | ... | @@ -618,6 +618,9 @@ async fn rest(State(app): State<Arc<App>>, request: Request) -> Response { |
| 618 | 618 | let grant = app |
| 619 | 619 | .mcp |
| 620 | 620 | .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 | } | |
| 621 | 624 | let id = string(&grant["id"]); |
| 622 | 625 | let method = request.method().clone(); |
| 623 | 626 | let path = request |
| ... | ... | @@ -778,13 +781,37 @@ impl ServerHandler for Agents { |
| 778 | 781 | .into()) |
| 779 | 782 | } |
| 780 | 783 | } |
| 784 | async 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 | } | |
| 781 | 797 | pub fn router(app: Arc<App>) -> Router { |
| 782 | 798 | let state = app.clone(); |
| 783 | 799 | let expected_host = |
| 784 | 800 | app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); |
| 801 | let agent = env("STUDIO_AGENT_DIR", "agent"); | |
| 785 | 802 | Router::new() |
| 786 | 803 | .route("/pairing", any(pairing)) |
| 787 | 804 | .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 | ) | |
| 788 | 815 | .route("/api/v1/{*path}", any(rest)) |
| 789 | 816 | .with_state(app.clone()) |
| 790 | 817 | .merge(mcp::router(app, "agents", move || { |
dashboard/src/shale.rs+41-11| ... | ... | @@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String { |
| 149 | 149 | fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> { |
| 150 | 150 | if repository.is_empty() |
| 151 | 151 | || repository.len() > 255 |
| 152 | || matches!(repository, "." | ".." | "-") | |
| 152 | || repository | |
| 153 | .split('/') | |
| 154 | .any(|part| matches!(part, "" | "." | ".." | "-")) | |
| 153 | 155 | || repository |
| 154 | 156 | .chars() |
| 155 | .any(|c| c.is_control() || c.is_whitespace() || "/\\%?#".contains(c)) | |
| 157 | .any(|c| c.is_control() || c.is_whitespace() || "\\%?#".contains(c)) | |
| 156 | 158 | { |
| 157 | 159 | return Err(Error::new( |
| 158 | 160 | 400, |
| ... | ... | @@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu |
| 164 | 166 | .path_segments_mut() |
| 165 | 167 | .unwrap() |
| 166 | 168 | .clear() |
| 167 | .push(repository) | |
| 169 | .extend(repository.split('/')) | |
| 168 | 170 | .extend(suffix.iter().copied()); |
| 169 | 171 | Ok(target) |
| 170 | 172 | } |
| ... | ... | @@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result<String> { |
| 184 | 186 | .map(str::to_owned) |
| 185 | 187 | .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab.")) |
| 186 | 188 | } |
| 187 | pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> { | |
| 189 | pub(crate) async fn verified_session(app: &App, owner: &str) -> Result<String> { | |
| 188 | 190 | let session = session(app, owner)?; |
| 189 | 191 | username( |
| 190 | 192 | &app.shale |
| 191 | 193 | .page(&app.shale.origin.join("/-/settings")?, &session) |
| 192 | 194 | .await?, |
| 193 | 195 | )?; |
| 196 | Ok(session) | |
| 197 | } | |
| 198 | pub async fn repositories(app: &App, owner: &str) -> Result<Vec<Value>> { | |
| 199 | let session = verified_session(app, owner).await?; | |
| 194 | 200 | let body = app.shale.page(&app.shale.origin, &session).await?; |
| 195 | 201 | let document = document(&body, "page-index", None)?; |
| 196 | 202 | let mut repositories = Vec::new(); |
| ... | ... | @@ -359,12 +365,12 @@ impl ServerHandler for Shale { |
| 359 | 365 | current(app, grant, &credential)?; |
| 360 | 366 | if name == "list_repositories" { |
| 361 | 367 | 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"])); | |
| 363 | 369 | current(app, grant, &credential)?; |
| 364 | 370 | return Ok(json!({"repositories":repositories})); |
| 365 | 371 | } |
| 366 | 372 | 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)) | |
| 368 | 374 | .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?; |
| 369 | 375 | let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; |
| 370 | 376 | let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { |
| ... | ... | @@ -556,6 +562,15 @@ pub async fn manage( |
| 556 | 562 | let owner_id = string(&owner["id"]); |
| 557 | 563 | let key = format!("shale-session:{owner_id}"); |
| 558 | 564 | 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 | }, | |
| 559 | 574 | Method::POST => { |
| 560 | 575 | let pending = if let Some(id) = body["request"].as_str() { |
| 561 | 576 | let db = app.mcp.db.lock().unwrap(); |
| ... | ... | @@ -740,10 +755,10 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response { |
| 740 | 755 | let _ = app.shale.get("/-/logout", Some(&session)).await; |
| 741 | 756 | return Err(error); |
| 742 | 757 | } |
| 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 | })?; | |
| 747 | 762 | Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response()) |
| 748 | 763 | }.await; |
| 749 | 764 | let mut response = match result { |
| ... | ... | @@ -779,7 +794,11 @@ mod tests { |
| 779 | 794 | "..", |
| 780 | 795 | "-", |
| 781 | 796 | "../other", |
| 782 | "one/two", | |
| 797 | "one//two", | |
| 798 | "one/../two", | |
| 799 | "one/./two", | |
| 800 | "one/-/two", | |
| 801 | "one/", | |
| 783 | 802 | "one\\two", |
| 784 | 803 | "%2e%2e", |
| 785 | 804 | "one?x", |
| ... | ... | @@ -795,6 +814,17 @@ mod tests { |
| 795 | 814 | let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap(); |
| 796 | 815 | assert_eq!(path.origin(), origin.origin()); |
| 797 | 816 | 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 | ); | |
| 798 | 828 | } |
| 799 | 829 | #[test] |
| 800 | 830 | 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>) { |
| 823 | 823 | .unwrap() |
| 824 | 824 | .iter() |
| 825 | 825 | .flat_map(|(id, job)| { |
| 826 | array(&job["job"]["TaskGroups"]).iter() | |
| 826 | array(&job["job"]["TaskGroups"]) | |
| 827 | .iter() | |
| 827 | 828 | .flat_map(|group| array(&group["Services"])) |
| 828 | 829 | .flat_map(move |service| { |
| 829 | 830 | 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 | ) | |
| 832 | 840 | }) |
| 833 | 841 | }) |
| 834 | 842 | }) |
| ... | ... | @@ -842,7 +850,10 @@ pub fn start(app: Arc<App>) { |
| 842 | 850 | let response = app |
| 843 | 851 | .request( |
| 844 | 852 | Method::GET, |
| 845 | &format!("{}{path}", endpoint(app.clone(), &service).await?), | |
| 853 | &format!( | |
| 854 | "{}{path}", | |
| 855 | endpoint(app.clone(), &service).await? | |
| 856 | ), | |
| 846 | 857 | )? |
| 847 | 858 | .timeout(Duration::from_secs(5)) |
| 848 | 859 | .send() |
| ... | ... | @@ -852,7 +863,10 @@ pub fn start(app: Arc<App>) { |
| 852 | 863 | Method::POST, |
| 853 | 864 | &format!( |
| 854 | 865 | "{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 | ]) | |
| 856 | 870 | ), |
| 857 | 871 | )? |
| 858 | 872 | .body(response.text().await?) |
dashboard/src/users.rs+264-369| ... | ... | @@ -1,85 +1,7 @@ |
| 1 | 1 | use crate::*; |
| 2 | 2 | use axum::extract::{FromRequest, Multipart}; |
| 3 | use futures::{StreamExt, stream}; | |
| 3 | use rusqlite::{OptionalExtension, params as sql}; | |
| 4 | 4 | |
| 5 | async 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 | } | |
| 10 | async fn get(path: &str) -> Result<Value> { | |
| 11 | Ok(call(path, Method::GET, None).await?["body"].take()) | |
| 12 | } | |
| 13 | async 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 | } | |
| 36 | async 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 | } | |
| 60 | async 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 | } | |
| 67 | async 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 | } | |
| 83 | 5 | fn uuid(id: &str) -> Result<()> { |
| 84 | 6 | if regex::Regex::new( |
| 85 | 7 | 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> { |
| 113 | 35 | let value = match key { |
| 114 | 36 | "username" => { |
| 115 | 37 | let v = string(value).trim().to_lowercase(); |
| 116 | if !username_pattern.is_match(&v) { | |
| 38 | if v.len() > 254 || !username_pattern.is_match(&v) { | |
| 117 | 39 | return Err(Error::new( |
| 118 | 40 | 400, |
| 119 | 41 | "Usernames use lowercase letters, digits, dots, dashes, and @", |
| ... | ... | @@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> { |
| 126 | 48 | .as_str() |
| 127 | 49 | .ok_or_else(|| Error::new(400, "Enter a name or email address."))? |
| 128 | 50 | .trim(); |
| 51 | if v.len() > 254 { | |
| 52 | return Err(Error::new(400, "Use a shorter name or email address.")); | |
| 53 | } | |
| 129 | 54 | if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) { |
| 130 | 55 | return Err(Error::new(400, "Enter a full email address")); |
| 131 | 56 | } |
| ... | ... | @@ -138,7 +63,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> { |
| 138 | 63 | value.clone() |
| 139 | 64 | } |
| 140 | 65 | _ => { |
| 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") { | |
| 142 | 67 | return Err(Error::new(400, "Invalid required actions.")); |
| 143 | 68 | } |
| 144 | 69 | value.clone() |
| ... | ... | @@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result<Value> { |
| 151 | 76 | fn password(value: &Value) -> Result<&str> { |
| 152 | 77 | value |
| 153 | 78 | .as_str() |
| 154 | .filter(|s| s.chars().count() >= 8) | |
| 79 | .filter(|s| s.chars().count() >= 8 && s.len() <= 1024) | |
| 155 | 80 | .ok_or_else(|| Error::new(400, "Use at least 8 characters")) |
| 156 | 81 | } |
| 157 | 82 | |
| 83 | pub(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 | ||
| 99 | pub 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 | ||
| 158 | 114 | pub async fn route( |
| 159 | 115 | app: Arc<App>, |
| 160 | 116 | method: &Method, |
| ... | ... | @@ -163,142 +119,207 @@ pub async fn route( |
| 163 | 119 | body: Value, |
| 164 | 120 | ) -> Result<Response> { |
| 165 | 121 | 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()); | |
| 170 | 142 | } |
| 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."))?; | |
| 187 | 180 | 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 | } | |
| 189 | 190 | } |
| 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)?; | |
| 210 | 193 | } |
| 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()?; | |
| 223 | 195 | } |
| 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 | )); | |
| 228 | 222 | } |
| 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)?; | |
| 230 | 233 | Value::Null |
| 231 | 234 | } |
| 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])?; | |
| 235 | 247 | Value::Null |
| 236 | 248 | } |
| 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 | )?; | |
| 246 | 271 | } |
| 247 | change_role( | |
| 248 | id, | |
| 249 | group, | |
| 250 | if method == Method::PUT { | |
| 251 | Method::POST | |
| 252 | } else { | |
| 253 | Method::DELETE | |
| 254 | }, | |
| 255 | ) | |
| 256 | .await?; | |
| 257 | 272 | Value::Null |
| 258 | 273 | } |
| 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])?; | |
| 264 | 277 | Value::Null |
| 265 | 278 | } |
| 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 | )?; | |
| 280 | 284 | Value::Null |
| 281 | 285 | } |
| 282 | [id, "password"] if method == Method::PUT => { | |
| 283 | let password = password(&body["password"])?; | |
| 286 | [_, "password"] if method == Method::PUT => { | |
| 284 | 287 | if !body["temporary"].is_boolean() { |
| 285 | 288 | return Err(Error::new( |
| 286 | 289 | 400, |
| 287 | 290 | "Choose whether this password is temporary.", |
| 288 | 291 | )); |
| 289 | 292 | } |
| 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])?; | |
| 296 | 301 | Value::Null |
| 297 | 302 | } |
| 298 | _ => return Err(Error::new(404, "Not Found")), | |
| 303 | _ => return Err(Error::new(404, "No account action here.")), | |
| 299 | 304 | }; |
| 300 | 305 | 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)?; | |
| 302 | 323 | } |
| 303 | 324 | Ok(if value.is_null() { |
| 304 | 325 | StatusCode::NO_CONTENT.into_response() |
| ... | ... | @@ -306,54 +327,6 @@ pub async fn route( |
| 306 | 327 | Document::new(value).response() |
| 307 | 328 | }) |
| 308 | 329 | } |
| 309 | async 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 | } | |
| 323 | pub 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 | } | |
| 357 | 330 | fn image_type(bytes: &[u8]) -> Option<&'static str> { |
| 358 | 331 | if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { |
| 359 | 332 | Some("image/webp") |
| ... | ... | @@ -396,130 +369,62 @@ pub async fn account( |
| 396 | 369 | me: &Value, |
| 397 | 370 | ) -> Result<Response> { |
| 398 | 371 | 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 | } | |
| 476 | 372 | let mut user = self_user(&app, me).await?; |
| 477 | 373 | let id = string(&user["id"]).to_owned(); |
| 478 | 374 | let value = match parts { |
| 479 | 375 | [] 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"); | |
| 488 | 379 | user |
| 489 | 380 | } |
| 490 | 381 | [] 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 | } | |
| 506 | 393 | } |
| 507 | 394 | } |
| 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)?; | |
| 514 | 402 | Value::Null |
| 515 | 403 | } |
| 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()?; | |
| 523 | 428 | Value::Null |
| 524 | 429 | } |
| 525 | 430 | ["picture"] if method == Method::PUT => { |
| ... | ... | @@ -554,32 +459,22 @@ pub async fn account( |
| 554 | 459 | tokio::fs::create_dir_all(app.data.join("pictures")).await?; |
| 555 | 460 | tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?; |
| 556 | 461 | let picture = format!( |
| 557 | "{origin}/api/account/pictures/{id}?v={}", | |
| 462 | "{}/api/account/pictures/{id}?v={}", | |
| 463 | app.auth.origin.origin().ascii_serialization(), | |
| 558 | 464 | (now() * 1000.0) as u64 |
| 559 | 465 | ); |
| 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)?; | |
| 566 | 468 | json!({"picture":picture}) |
| 567 | 469 | } |
| 568 | 470 | ["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)?; | |
| 575 | 473 | let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await; |
| 576 | 474 | Value::Null |
| 577 | 475 | } |
| 578 | _ => return Err(Error::new(404, "Not Found")), | |
| 476 | _ => return Err(Error::new(404, "No account action here.")), | |
| 579 | 477 | }; |
| 580 | if method != Method::GET { | |
| 581 | app.cache.invalidate("users"); | |
| 582 | } | |
| 583 | 478 | Ok(if value.is_null() { |
| 584 | 479 | StatusCode::NO_CONTENT.into_response() |
| 585 | 480 | } else { |
dashboard/web/api.contract.ts+16-17| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { Connections, Consent } from "./types/mcp.ts"; | |
| 1 | import type { Access, Connections, Consent, Resources } from "./types/mcp.ts"; | |
| 2 | 2 | import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts"; |
| 3 | 3 | import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts"; |
| 4 | 4 | import 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 |
| 55 | 55 | |
| 56 | 56 | export type Api = Hono<{}, { |
| 57 | 57 | "/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>; }; | |
| 59 | 59 | "/mcp/consent/:id": { |
| 60 | 60 | $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 } }>; | |
| 62 | 62 | }; |
| 63 | 63 | "/mcp/relay/pair": { $post: Endpoint<Connections["machines"][number], { json: { code: string } }>; }; |
| 64 | 64 | "/mcp/relay/machines/:id": { |
| ... | ... | @@ -66,7 +66,11 @@ export type Api = Hono<{}, { |
| 66 | 66 | $delete: Endpoint<null, { param: { id: string } }, 204>; |
| 67 | 67 | }; |
| 68 | 68 | "/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 | }; | |
| 70 | 74 | |
| 71 | 75 | "/me": { |
| 72 | 76 | $get: Endpoint<Me>; |
| ... | ... | @@ -210,7 +214,7 @@ export type Api = Hono<{}, { |
| 210 | 214 | }; |
| 211 | 215 | "/users": { |
| 212 | 216 | $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>; | |
| 214 | 218 | }; |
| 215 | 219 | "/users/:id": { |
| 216 | 220 | $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<{}, { |
| 226 | 230 | "/users/:id/logout": { |
| 227 | 231 | $post: Endpoint<null, { param: { id: string; }; }, 204, "body">; |
| 228 | 232 | }; |
| 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">; | |
| 231 | 236 | }; |
| 232 | 237 | "/users/:id/password": { |
| 233 | 238 | $put: Endpoint<null, { param: { id: string; }; } & { json: { password: string; temporary: boolean; }; }, 204, "body">; |
| ... | ... | @@ -283,11 +288,8 @@ export type Api = Hono<{}, { |
| 283 | 288 | $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">; |
| 284 | 289 | }; |
| 285 | 290 | "/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">; | |
| 291 | 293 | }; |
| 292 | 294 | "/account/picture": { |
| 293 | 295 | $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; |
| ... | ... | @@ -296,10 +298,7 @@ export type Api = Hono<{}, { |
| 296 | 298 | "/account/pictures/:id": { |
| 297 | 299 | $get: Endpoint<Uint8Array, { param: { id: string; }; }, 200, "body">; |
| 298 | 300 | }; |
| 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">; | |
| 304 | 303 | }; |
| 305 | 304 | } & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>; |
dashboard/web/auth.ts created+24| ... | ... | @@ -0,0 +1,24 @@ |
| 1 | export 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 | ||
| 9 | export 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 | ||
| 17 | export async function signOut() { | |
| 18 | await authRequest("sign-out", {}); | |
| 19 | window.location.assign("/sign-in"); | |
| 20 | } | |
| 21 | ||
| 22 | export const authReason = (failure: unknown) => failure instanceof DetailedError ? reason(failure) : failure instanceof Error ? failure.message : "Couldn't finish sign-in. Try again."; | |
| 23 | import { DetailedError } from "hono/client"; | |
| 24 | import { 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"; |
| 19 | 19 | import { queries } from "../api.ts"; |
| 20 | 20 | import snowflake from "../snowflake.svg"; |
| 21 | 21 | import { bytes, cores, plural } from "../format.ts"; |
| 22 | import { signOut } from "../auth.ts"; | |
| 22 | 23 | import { account, Avatar, displayName } from "../pages/Account.tsx"; |
| 23 | 24 | import { status } from "../pages/Overview.tsx"; |
| 24 | 25 | import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; |
| ... | ... | @@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) { |
| 243 | 244 | </button> |
| 244 | 245 | <div ref={menu} id="account-menu" popover class="account-menu"> |
| 245 | 246 | <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> | |
| 247 | 248 | <Show when={props.me.viewing || props.me.sections.includes("admin")}> |
| 248 | 249 | <form class="view-as" onSubmit={(event) => { |
| 249 | 250 | event.preventDefault(); |
dashboard/web/main.tsx+8| ... | ... | @@ -20,6 +20,8 @@ import { Deploys } from "./pages/Deploys.tsx"; |
| 20 | 20 | import { Deploy } from "./pages/Deploy.tsx"; |
| 21 | 21 | import { VMs } from "./pages/VMs.tsx"; |
| 22 | 22 | import { MCP } from "./pages/MCP.tsx"; |
| 23 | import { MCPConsent } from "./pages/MCPConsent.tsx"; | |
| 24 | import { SignIn } from "./pages/SignIn.tsx"; | |
| 23 | 25 | import { Account } from "./pages/Account.tsx"; |
| 24 | 26 | import "./styles.css"; |
| 25 | 27 | |
| ... | ... | @@ -30,10 +32,12 @@ const preload = (...list: { preload(): void }[]) => () => list.forEach((query) = |
| 30 | 32 | |
| 31 | 33 | function Shell(props: RouteSectionProps) { |
| 32 | 34 | const location = useLocation(); |
| 35 | const consent = () => location.pathname.startsWith("/connect/") || location.pathname === "/mcp" && new URLSearchParams(location.search).has("request"); | |
| 33 | 36 | const section = () => location.pathname.startsWith("/services/") ? "admin" |
| 34 | 37 | : PAGES.find((page) => page.href !== "/" && location.pathname.startsWith(page.href))?.section; |
| 35 | 38 | return ( |
| 36 | 39 | <> |
| 40 | <Show when={location.pathname !== "/sign-in" && !consent()} fallback={<Show when={consent()} fallback={props.children}><MCPConsent /></Show>}> | |
| 37 | 41 | <Loaded data={me} what="your account" retry={refetch} skeleton={<div class="shell"><div class="sidebar" /><main class="main" /></div>}> |
| 38 | 42 | {(user) => ( |
| 39 | 43 | <div class="shell"> |
| ... | ... | @@ -55,6 +59,7 @@ function Shell(props: RouteSectionProps) { |
| 55 | 59 | </div> |
| 56 | 60 | )} |
| 57 | 61 | </Loaded> |
| 62 | </Show> | |
| 58 | 63 | <Tooltips /> |
| 59 | 64 | <Toasts /> |
| 60 | 65 | </> |
| ... | ... | @@ -63,6 +68,7 @@ function Shell(props: RouteSectionProps) { |
| 63 | 68 | |
| 64 | 69 | render(() => ( |
| 65 | 70 | <Router root={Shell}> |
| 71 | <Route path="/sign-in" component={SignIn} /> | |
| 66 | 72 | <Route path="/" component={() => <Overview me={me.latest!} />} preload={() => { |
| 67 | 73 | queries.launcher.preload(); |
| 68 | 74 | if (me.latest?.sections.includes("metrics")) preload(queries.host, queries.storage, queries.services)(); |
| ... | ... | @@ -81,6 +87,8 @@ render(() => ( |
| 81 | 87 | <Route path="/deploys/:id/:tab?" component={Deploy} preload={preload(queries.deploys, queries.services)} /> |
| 82 | 88 | <Route path="/vms" component={VMs} preload={preload(queries.vms)} /> |
| 83 | 89 | <Route path="/mcp" component={MCP} /> |
| 90 | <Route path="/mcp/settings/:catalog" component={MCP} /> | |
| 91 | <Route path="/connect/:id" component={MCPConsent} /> | |
| 84 | 92 | <Route path="/account" component={Account} preload={preload(queries.launcher)} /> |
| 85 | 93 | <Route path="*" component={() => <div class="empty">No page here</div>} /> |
| 86 | 94 | </Router> |
dashboard/web/pages/Account.tsx+65-69| ... | ... | @@ -1,17 +1,18 @@ |
| 1 | import { useNavigate, useSearchParams } from "@solidjs/router"; | |
| 2 | import { createResource, createSignal, For, onMount, Show } from "solid-js"; | |
| 1 | import { useSearchParams } from "@solidjs/router"; | |
| 2 | import { createResource, createSignal, For, Show } from "solid-js"; | |
| 3 | 3 | import { parseResponse } from "hono/client"; |
| 4 | 4 | import type { User } from "../types/users.ts"; |
| 5 | import { api, queries, reason } from "../api.ts"; | |
| 5 | import { api, reason } from "../api.ts"; | |
| 6 | 6 | import { Ago } from "../components/Ago.tsx"; |
| 7 | 7 | import { lastGood, Loaded } from "../components/Loaded.tsx"; |
| 8 | import { OpenApp } from "../components/OpenApp.tsx"; | |
| 8 | import { showConfirmDialog } from "../components/Dialog.tsx"; | |
| 9 | import { addPasskey, authReason, authRequest } from "../auth.ts"; | |
| 9 | 10 | import { Reveal } from "../components/Reveal.tsx"; |
| 10 | 11 | import { toast } from "../components/Toast.tsx"; |
| 11 | 12 | import "./Account.css"; |
| 12 | 13 | |
| 13 | /** The signed-in user's Keycloak record, shared with the sidebar corner. */ | |
| 14 | export const [account, { refetch: refetchAccount }] = createResource(() => parseResponse(api.account.$get())); | |
| 14 | /** Shared with the sidebar corner. */ | |
| 15 | export const [account, { refetch: refetchAccount }] = createResource(() => window.location.pathname !== "/sign-in", () => parseResponse(api.account.$get())); | |
| 15 | 16 | |
| 16 | 17 | export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) => |
| 17 | 18 | [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username; |
| ... | ... | @@ -25,14 +26,7 @@ export function Avatar(props: { picture: string | null; name: string }) { |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | const PICTURE_SIZE = 256; |
| 28 | const FIELDS = ["firstName", "lastName"] as const; | |
| 29 | ||
| 30 | const 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 | }; | |
| 29 | const FIELDS = ["firstName", "lastName", "email"] as const; | |
| 36 | 30 | |
| 37 | 31 | /** 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. */ |
| 38 | 32 | async function square(file: File) { |
| ... | ... | @@ -48,24 +42,12 @@ async function square(file: File) { |
| 48 | 42 | } |
| 49 | 43 | |
| 50 | 44 | export 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); | |
| 62 | 47 | return ( |
| 63 | 48 | <div class="page account-page"> |
| 64 | 49 | <div class="page-head"> |
| 65 | 50 | <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> | |
| 69 | 51 | </div> |
| 70 | 52 | <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={ |
| 71 | 53 | <div class="account-grid"> |
| ... | ... | @@ -75,6 +57,20 @@ export function Account() { |
| 75 | 57 | }> |
| 76 | 58 | {(user) => ( |
| 77 | 59 | <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> | |
| 78 | 74 | <Profile user={user()} /> |
| 79 | 75 | <SignIn user={user()} /> |
| 80 | 76 | </div> |
| ... | ... | @@ -88,7 +84,7 @@ type Self = NonNullable<typeof account.latest>; |
| 88 | 84 | |
| 89 | 85 | function Profile(props: { user: Self }) { |
| 90 | 86 | const [dirty, setDirty] = createSignal(false); |
| 91 | const [pending, setPending] = createSignal<"save" | "picture" | "verify">(); | |
| 87 | const [pending, setPending] = createSignal<"save" | "picture">(); | |
| 92 | 88 | const [error, setError] = createSignal(""); |
| 93 | 89 | const [pictureError, setPictureError] = createSignal(""); |
| 94 | 90 | const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>; |
| ... | ... | @@ -103,7 +99,7 @@ function Profile(props: { user: Self }) { |
| 103 | 99 | setError(""); |
| 104 | 100 | }; |
| 105 | 101 | |
| 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) => { | |
| 107 | 103 | setPending(key); |
| 108 | 104 | fail(""); |
| 109 | 105 | try { |
| ... | ... | @@ -141,11 +137,6 @@ function Profile(props: { user: Self }) { |
| 141 | 137 | await refetchAccount(); |
| 142 | 138 | }, setPictureError); |
| 143 | 139 | |
| 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 | ||
| 149 | 140 | return ( |
| 150 | 141 | <section class="card"> |
| 151 | 142 | <div class="picture-row"> |
| ... | ... | @@ -175,16 +166,8 @@ function Profile(props: { user: Self }) { |
| 175 | 166 | <label class="label" for="last-name">last name</label> |
| 176 | 167 | <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name" |
| 177 | 168 | 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"} /> | |
| 188 | 171 | <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show> |
| 189 | 172 | <span /> |
| 190 | 173 | <Reveal when={dirty()}> |
| ... | ... | @@ -206,28 +189,41 @@ function Profile(props: { user: Self }) { |
| 206 | 189 | function SignIn(props: { user: Self }) { |
| 207 | 190 | const password = () => props.user.credentials.find((credential) => credential.type === "password"); |
| 208 | 191 | 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>; | |
| 233 | 229 | } |
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; } | |
| 6 | 34 | .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 @@ |
| 1 | import { useLocation } from "@solidjs/router"; | |
| 1 | import { A, useParams } from "@solidjs/router"; | |
| 2 | 2 | import { parseResponse } from "hono/client"; |
| 3 | 3 | import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; |
| 4 | 4 | import { api, reason } from "../api.ts"; |
| 5 | 5 | import { Ago } from "../components/Ago.tsx"; |
| 6 | 6 | import { Checkbox } from "../components/Checkbox.tsx"; |
| 7 | 7 | import { Copy } from "../components/Copy.tsx"; |
| 8 | import { showConfirmDialog } from "../components/Dialog.tsx"; | |
| 8 | 9 | import { Loaded } from "../components/Loaded.tsx"; |
| 9 | 10 | import { toast } from "../components/Toast.tsx"; |
| 11 | import { MCPAccess } from "./MCPAccess.tsx"; | |
| 12 | import type { Access, Catalog } from "../types/mcp.ts"; | |
| 10 | 13 | import "./MCP.css"; |
| 11 | 14 | |
| 15 | const 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 | ||
| 12 | 21 | export function MCP() { |
| 13 | const location = useLocation(); | |
| 14 | const request = () => new URLSearchParams(location.search).get("request"); | |
| 22 | const params = useParams<{ catalog?: string }>(); | |
| 15 | 23 | 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 | }); | |
| 19 | 34 | const [busy, setBusy] = createSignal(false); |
| 20 | 35 | const [code, setCode] = createSignal(""); |
| 21 | 36 | const [keyName, setKeyName] = createSignal(""); |
| 22 | 37 | const [keyMachines, setKeyMachines] = createSignal<string[]>([]); |
| 23 | 38 | const [control, setControl] = createSignal(false); |
| 24 | 39 | const [key, setKey] = createSignal(""); |
| 25 | createEffect(() => { request(); setPicked([]); setBusy(false); }); | |
| 26 | let events: EventSource | undefined; | |
| 40 | createEffect(() => { params.catalog; setEditing(""); setKey(""); }); | |
| 27 | 41 | 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"); | |
| 30 | 44 | events.onmessage = (event) => { |
| 31 | 45 | const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data); |
| 32 | 46 | mutate((previous) => previous && { ...previous, machines }); |
| 33 | 47 | }; |
| 48 | onCleanup(() => events.close()); | |
| 34 | 49 | }); |
| 35 | onCleanup(() => events?.close()); | |
| 36 | 50 | const linkShale = async () => { |
| 37 | 51 | 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); } | |
| 49 | 54 | }; |
| 50 | 55 | 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> | |
| 80 | 76 | </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"}> | |
| 89 | 97 | <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> | |
| 92 | 104 | <form class="mcp-actions" onSubmit={async (event) => { |
| 93 | 105 | event.preventDefault(); setBusy(true); |
| 94 | 106 | try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } |
| ... | ... | @@ -96,7 +108,7 @@ export function MCP() { |
| 96 | 108 | finally { setBusy(false); } |
| 97 | 109 | }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label> |
| 98 | 110 | <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>}> | |
| 100 | 112 | <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody> |
| 101 | 113 | <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td> |
| 102 | 114 | <button class="button small" onClick={async () => { |
| ... | ... | @@ -118,19 +130,48 @@ export function MCP() { |
| 118 | 130 | <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button> |
| 119 | 131 | </form> |
| 120 | 132 | </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> | |
| 121 | 144 | <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 | }} | |
| 134 | 175 | </Loaded> |
| 135 | 176 | </div>; |
| 136 | 177 | } |
dashboard/web/pages/MCPAccess.tsx created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | import { For, Show } from "solid-js"; | |
| 2 | import { Checkbox } from "../components/Checkbox.tsx"; | |
| 3 | import type { Access, Catalog, Resources } from "../types/mcp.ts"; | |
| 4 | ||
| 5 | export 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 @@ |
| 1 | import { useBeforeLeave, useLocation, useParams } from "@solidjs/router"; | |
| 2 | import { parseResponse } from "hono/client"; | |
| 3 | import { createEffect, createResource, createSignal, on, onCleanup, onMount, Show } from "solid-js"; | |
| 4 | import { api, reason } from "../api.ts"; | |
| 5 | import { showConfirmDialog } from "../components/Dialog.tsx"; | |
| 6 | import { MCPAccess } from "./MCPAccess.tsx"; | |
| 7 | import type { Access } from "../types/mcp.ts"; | |
| 8 | import "./MCP.css"; | |
| 9 | ||
| 10 | export 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 @@ |
| 1 | import { createResource, createSignal, Show } from "solid-js"; | |
| 2 | import { authReason, authRequest } from "../auth.ts"; | |
| 3 | import "./SignIn.css"; | |
| 4 | ||
| 5 | export 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"; |
| 15 | 15 | import { Copy } from "../components/Copy.tsx"; |
| 16 | 16 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; |
| 17 | 17 | import { ListPage } from "../components/ListPage.tsx"; |
| 18 | import { OpenApp } from "../components/OpenApp.tsx"; | |
| 19 | 18 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; |
| 20 | 19 | import { Reveal } from "../components/Reveal.tsx"; |
| 21 | 20 | import { PAGES } from "../components/Sidebar.tsx"; |
| ... | ... | @@ -24,13 +23,7 @@ import { toast } from "../components/Toast.tsx"; |
| 24 | 23 | import { ago, count, date, datetime, plural } from "../format.ts"; |
| 25 | 24 | import "./Users.css"; |
| 26 | 25 | |
| 27 | const 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 | ]; | |
| 26 | const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]]; | |
| 34 | 27 | |
| 35 | 28 | const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const; |
| 36 | 29 | |
| ... | ... | @@ -50,7 +43,7 @@ const inState = (user: User, state: keyof typeof STATES) => |
| 50 | 43 | |
| 51 | 44 | /** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */ |
| 52 | 45 | function reach(groups: string[], services: ServiceSummary[]) { |
| 53 | const apps = services.filter((app) => app.url); | |
| 46 | const apps = services.filter((app) => app.id === "copyparty" && app.url); | |
| 54 | 47 | const open = { |
| 55 | 48 | apps: apps.filter((app) => canOpen(groups, app.access)), |
| 56 | 49 | pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)), |
| ... | ... | @@ -79,9 +72,9 @@ function groupTip(group: string, d: Data) { |
| 79 | 72 | return `${plural(members, "member")} · opens ${opens}`; |
| 80 | 73 | } |
| 81 | 74 | |
| 82 | /** The app behind a Keycloak `clientId`, which is its service id. */ | |
| 75 | ||
| 83 | 76 | const 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; | |
| 85 | 78 | |
| 86 | 79 | /** "active 3h ago in Jellyfin, Shale", from their open sessions. */ |
| 87 | 80 | function seen(user: User, services: ServiceSummary[]) { |
| ... | ... | @@ -98,15 +91,6 @@ function network(ip: string) { |
| 98 | 91 | if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network"; |
| 99 | 92 | } |
| 100 | 93 | |
| 101 | /** Keycloak's admin console at `path` inside the realm. */ | |
| 102 | function 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 | ||
| 110 | 94 | /** Where the list was scrolled when a user page opened, so going back lands in the same place. */ |
| 111 | 95 | let listScroll = 0; |
| 112 | 96 | /** 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>; |
| 204 | 188 | <div class="field"> |
| 205 | 189 | first sign-in |
| 206 | 190 | <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> | |
| 208 | 192 | <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button> |
| 209 | 193 | </TabBar> |
| 210 | 194 | </div> |
| 211 | 195 | <Show when={!invite()}> |
| 212 | 196 | <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" /> | |
| 214 | 198 | <span class="hint">They pick their own at first sign-in</span> |
| 215 | 199 | </label> |
| 216 | 200 | </Show> |
| ... | ... | @@ -219,16 +203,17 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; |
| 219 | 203 | }, |
| 220 | 204 | onConfirm: async (form) => { |
| 221 | 205 | 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({ | |
| 223 | 207 | json: { |
| 224 | 208 | profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") }, |
| 225 | 209 | 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" }, | |
| 227 | 211 | }, |
| 228 | 212 | })); |
| 229 | 213 | await props.refetch(); |
| 230 | 214 | const user = ready()?.users.find((user) => user.id === id); |
| 231 | 215 | 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 () => {} }); | |
| 232 | 217 | }, |
| 233 | 218 | }); |
| 234 | 219 | |
| ... | ... | @@ -236,7 +221,6 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; |
| 236 | 221 | <ListPage id="users" flush head={ |
| 237 | 222 | <div class="page-head"> |
| 238 | 223 | <h1>users</h1> |
| 239 | <KeycloakLink services={ready()?.services} path="users" /> | |
| 240 | 224 | <span class="spacer" /> |
| 241 | 225 | <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}> |
| 242 | 226 | <UserPlus size={14} />new user |
| ... | ... | @@ -418,7 +402,6 @@ function Person(props: { name: string; data: Resource<Data>; refetch: () => unkn |
| 418 | 402 | } |
| 419 | 403 | |
| 420 | 404 | function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 421 | const navigate = useNavigate(); | |
| 422 | 405 | const back = useBack(); |
| 423 | 406 | const param = () => ({ id: props.user.id }); |
| 424 | 407 | const [credentials, credentialActions] = credentialsOf.use(() => props.user.id); |
| ... | ... | @@ -450,23 +433,22 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 450 | 433 | await patch({ enabled }); |
| 451 | 434 | if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) }); |
| 452 | 435 | }); |
| 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"); | |
| 461 | 444 | }); |
| 462 | 445 | |
| 463 | const setPassword = () => showTextDialog({ | |
| 446 | const setPassword = () => showConfirmDialog({ | |
| 464 | 447 | 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></>, | |
| 467 | 449 | 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") ?? ""); | |
| 470 | 452 | await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } })); |
| 471 | 453 | await Promise.all([props.refetch(), credentialActions.refetch()]); |
| 472 | 454 | }, |
| ... | ... | @@ -484,7 +466,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 484 | 466 | const name = props.user.username; |
| 485 | 467 | showTextDialog({ |
| 486 | 468 | 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.`, | |
| 488 | 470 | label: `type ${name} to confirm`, |
| 489 | 471 | validateInput: (value) => value === name, |
| 490 | 472 | confirmLabel: "delete user", |
| ... | ... | @@ -499,7 +481,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 499 | 481 | }; |
| 500 | 482 | |
| 501 | 483 | 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 | ||
| 503 | 485 | const used = () => { |
| 504 | 486 | const last = new Map<string, number>(); |
| 505 | 487 | for (const session of props.user.sessions) { |
| ... | ... | @@ -517,7 +499,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 517 | 499 | <h1>{props.user.username}</h1> |
| 518 | 500 | <span class="sub">{fullName(props.user)}</span> |
| 519 | 501 | <StateTag user={props.user} /> |
| 520 | <KeycloakLink services={props.data.services} path={`users/${props.user.id}/settings`} /> | |
| 521 | 502 | <span class="spacer" /> |
| 522 | 503 | <button class="button danger" onClick={remove}>delete user</button> |
| 523 | 504 | </div> |
| ... | ... | @@ -556,7 +537,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 556 | 537 | {(list) => { |
| 557 | 538 | const password = () => list().find((c) => c.type === "password"); |
| 558 | 539 | const passkeys = () => list().filter((c) => c.type.startsWith("webauthn")); |
| 559 | const otp = () => list().find((c) => c.type === "otp"); | |
| 560 | 540 | return ( |
| 561 | 541 | <dl class="kv"> |
| 562 | 542 | <dt>password</dt> |
| ... | ... | @@ -567,33 +547,15 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 567 | 547 | {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>} |
| 568 | 548 | </For> |
| 569 | 549 | </dd> |
| 570 | <dt>authenticator</dt> | |
| 571 | <dd>{otp() ? `added ${date(otp()!.createdDate / 1000)}` : "none"}</dd> | |
| 572 | 550 | </dl> |
| 573 | 551 | ); |
| 574 | 552 | }} |
| 575 | 553 | </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> | |
| 597 | 559 | </section> |
| 598 | 560 | </div> |
| 599 | 561 | |
| ... | ... | @@ -601,11 +563,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 601 | 563 | <section class="card"> |
| 602 | 564 | <h2 class="card-title">profile</h2> |
| 603 | 565 | <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> | |
| 609 | 567 | <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} /> |
| 610 | 568 | <span /> |
| 611 | 569 | <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"} |
dashboard/web/types/mcp.ts+11-3| ... | ... | @@ -1,12 +1,20 @@ |
| 1 | export type Catalog = "shale" | "agents" | "observability"; | |
| 2 | export type Access = "all" | string[]; | |
| 3 | export type Resources = { id: string; name: string; description?: string }[]; | |
| 4 | ||
| 1 | 5 | export 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 }[]; | |
| 4 | 8 | machines: { id: string; name: string; platform: string; online: boolean }[]; |
| 5 | 9 | shale: { linkedAt: number } | null; |
| 6 | 10 | } |
| 7 | 11 | export interface Consent { |
| 8 | 12 | linked: boolean; |
| 9 | 13 | client: string; |
| 14 | catalog: Catalog; | |
| 15 | account: string; | |
| 16 | redirectHost: string; | |
| 10 | 17 | scopes: string[]; |
| 11 | resources: { id: string; name: string }[]; | |
| 18 | resources: Resources; | |
| 19 | resourceError: string | null; | |
| 12 | 20 | } |
dashboard/web/types/users.ts-3| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | /** Field names and millisecond timestamps follow Keycloak's admin representations. */ | |
| 2 | 1 | export interface User { |
| 3 | 2 | id: string; |
| 4 | 3 | username: string; |
| ... | ... | @@ -10,7 +9,6 @@ export interface User { |
| 10 | 9 | createdTimestamp: number; |
| 11 | 10 | requiredActions: string[]; |
| 12 | 11 | groups: Group[]; |
| 13 | /** Keycloak replaces the whole map on update, so send it merged. */ | |
| 14 | 12 | attributes?: Record<string, string[]>; |
| 15 | 13 | } |
| 16 | 14 | |
| ... | ... | @@ -31,6 +29,5 @@ export interface Session { |
| 31 | 29 | ipAddress: string; |
| 32 | 30 | start: number; |
| 33 | 31 | lastAccess: number; |
| 34 | /** Client UUID to `clientId`. */ | |
| 35 | 32 | clients: Record<string, string>; |
| 36 | 33 | } |
nixos/configuration.nix+3| ... | ... | @@ -158,6 +158,9 @@ in |
| 158 | 158 | STUDIO_INDEX_DIR = "/data/index"; |
| 159 | 159 | STUDIO_INTERNAL_URL = "https://dashboard.internal.${config.environment.variables.STUDIO_DOMAIN}:${toString internalPort}"; |
| 160 | 160 | 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}"; | |
| 161 | 164 | STUDIO_PUBLIC_ORIGIN = "https://snowglobe.${config.environment.variables.STUDIO_DOMAIN}"; |
| 162 | 165 | STUDIO_KEYCLOAK_URL = "https://auth.${config.environment.variables.STUDIO_DOMAIN}"; |
| 163 | 166 | 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 }: | |
| 2 | 2 | let |
| 3 | 3 | nativePkl = callPackage ./pkl.nix { }; |
| 4 | 4 | youtubePython = python3.withPackages (packages: [ packages.pyyaml ]); |
| ... | ... | @@ -38,11 +38,12 @@ let |
| 38 | 38 | root = ../dashboard; |
| 39 | 39 | fileset = lib.fileset.unions [ |
| 40 | 40 | ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock |
| 41 | ../dashboard/agent/install.sh ../dashboard/agent/install.ps1 | |
| 41 | 42 | ]; |
| 42 | 43 | }; |
| 43 | 44 | cargoLock.lockFile = ../dashboard/Cargo.lock; |
| 44 | 45 | nativeBuildInputs = [ pkg-config ]; |
| 45 | buildInputs = [ sqlite ]; | |
| 46 | buildInputs = [ sqlite openssl ]; | |
| 46 | 47 | LIBSQLITE3_SYS_USE_PKG_CONFIG = "1"; |
| 47 | 48 | }; |
| 48 | 49 | dashboard = runCommand "home-dashboard-0.1.0" { |
| ... | ... | @@ -67,5 +68,6 @@ let |
| 67 | 68 | ln -s ${server}/bin/home-dashboard $out/bin/home-dashboard |
| 68 | 69 | ln -s ${web} $out/lib/home-dashboard/dist |
| 69 | 70 | ln -s ${../dashboard/server} $out/lib/home-dashboard/server |
| 71 | ln -s ${../dashboard/agent} $out/lib/home-dashboard/agent | |
| 70 | 72 | ''; |
| 71 | 73 | in dashboard |
readme.md+109-1| ... | ... | @@ -123,8 +123,24 @@ root, private network, resource limits, and explicit data mounts. The small |
| 123 | 123 | performs bounded ZFS, VM, deployment, host-sampling, and identity operations. |
| 124 | 124 | Host control sockets and management credentials stay outside the container. |
| 125 | 125 | |
| 126 | Snowglobe and Copyparty use the Rust dashboard's accounts and host-only sessions. | |
| 127 | Account state lives in `/var/lib/studio/dashboard/accounts.sqlite`. Deployment | |
| 128 | backups include consistent SQLite copies and profile pictures; `data-restore` | |
| 129 | accepts `dashboard` and preserves a safety copy before restoring. Invitations reserve a username and groups, | |
| 130 | expire after 24 hours, and can be revoked. Setup offers optional passkey enrollment. | |
| 131 | Existing passkeys retain the `auth.paperclover.net` RP ID; that host serves related | |
| 132 | origin metadata for Snowglobe. Keycloak remains available for other services. | |
| 133 | ||
| 134 | `tools/import-dashboard-auth.py --host root@zenith --output /private/path/accounts.json` | |
| 135 | exports account IDs, groups, password hashes and public passkey credentials without | |
| 136 | changing the source realm. Keep the export private. The dashboard imports | |
| 137 | `accounts-import.json` from its data directory at startup and removes it after | |
| 138 | success; importing the same export again is safe, while a different export is | |
| 139 | refused once accounts exist. `home-dashboard --import-accounts /private/path/accounts.json` | |
| 140 | supports an offline rehearsal with a separate `STUDIO_DATA_DIR`. | |
| 141 | ||
| 126 | 142 | The MCP tab manages separate observability, agent, and Shale catalogs through |
| 127 | the existing Keycloak realm. Each connection has explicit service, machine, or | |
| 143 | the native dashboard account. Each connection has explicit service, machine, or | |
| 128 | 144 | repository grants; Shale credentials belong to the signed-in user. Agent Relay's |
| 129 | 145 | existing outbound client protocol connects to the Rust server. |
| 130 | 146 | |
| ... | ... | @@ -134,3 +150,95 @@ rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP |
| 134 | 150 | connectors with disposable fixtures. `--relay-agent-dir` includes the existing |
| 135 | 151 | Agent Relay client interoperability check; `--browser-ready-file` temporarily |
| 136 | 152 | routes the public dashboard to the fixture for browser and SSO load checks. |
| 153 | ||
| 154 | ## local agents | |
| 155 | ||
| 156 | On each Mac or Linux machine, run this from your usual terminal: | |
| 157 | ||
| 158 | ```sh | |
| 159 | curl -fsSL https://snowglobe.paperclover.net/agent/install.sh | sh | |
| 160 | ``` | |
| 161 | ||
| 162 | On Windows, use a PowerShell window without administrator privileges: | |
| 163 | ||
| 164 | ```powershell | |
| 165 | irm https://snowglobe.paperclover.net/agent/install.ps1 | iex | |
| 166 | ``` | |
| 167 | ||
| 168 | The installer downloads a private Node runtime, verifies its SHA-256 checksum, | |
| 169 | and installs the agent without npm or a repository checkout. On NixOS, it | |
| 170 | installs the runtime through Nix into the agent folder. Prompts ask for a | |
| 171 | machine name, existing project folders where new chats may start, and optional | |
| 172 | experimental Codex desktop control on macOS or Linux. No folders or desktop | |
| 173 | control are enabled on a fresh install unless selected. Codex or Claude Code | |
| 174 | must already be installed and signed in as your login user. | |
| 175 | ||
| 176 | While the installer waits, open **MCP → Settings → Local agents**, enter the printed | |
| 177 | pairing code, and click **Link machine**. Finish installation in the terminal. | |
| 178 | The machine appears **Online** when its background agent connects. Pairing | |
| 179 | belongs to the signed-in dashboard account; each machine connects outward and | |
| 180 | needs no incoming firewall port. Startup uses a systemd user service on Linux, | |
| 181 | a LaunchAgent on macOS, and a current-user scheduled task at logon on Windows. | |
| 182 | Linux needs an active systemd user session. The agent starts immediately after | |
| 183 | installation and again at login. | |
| 184 | ||
| 185 | Copy **Local agents**' endpoint, `https://snowglobe.paperclover.net/mcp/agents`, | |
| 186 | into the AI client's MCP connector settings using OAuth. Sign in to the | |
| 187 | dashboard and select the machines the client may access. The consent screen | |
| 188 | shows whether the connection requests session control. A granted machine | |
| 189 | exposes saved Codex and Claude Code chats; project folders constrain **new** | |
| 190 | chats, not saved-chat reads or the permissions of existing chats. | |
| 191 | ||
| 192 | Example requests to the connected AI client: | |
| 193 | ||
| 194 | ```text | |
| 195 | List my machines, target "Work PC", and show its recent Codex chats. | |
| 196 | Read the latest chat in that list. | |
| 197 | Start a Codex chat on "Work PC" in C:\Users\Clover\dev\site: | |
| 198 | check the build and fix the failing tests. | |
| 199 | Read that chat again to check the result. | |
| 200 | ``` | |
| 201 | ||
| 202 | Read access supports listing machines and chats and reading transcripts. | |
| 203 | Session control adds starting chats, sending messages, and interrupting turns. | |
| 204 | Writes always select one machine. Agent-owned Codex and Claude Code chats | |
| 205 | support these operations; existing Codex desktop control is experimental and | |
| 206 | requires the installer option. Existing Claude Code chats accept messages only | |
| 207 | when their local inbox supports delivery. Windows installs support saved-chat | |
| 208 | reads and agent-owned CLI chats; this installer does not enable existing | |
| 209 | desktop chat control there. A submitted message acknowledges delivery; read | |
| 210 | the chat again for its result. Commands needing local approval are refused. | |
| 211 | ||
| 212 | For an external script, create an **API key** in the MCP tab, select its | |
| 213 | machines, and enable **Allow session control** only if required. Use the key | |
| 214 | as a bearer token with `/api/v1/machines` and | |
| 215 | `/api/v1/machines/{id}/commands`. Keep it in the script's secret store. Unlinking | |
| 216 | a machine disconnects it and revokes its machine credential; revoking a client | |
| 217 | connection removes only that client's access. | |
| 218 | ||
| 219 | Rerun the install command to update the agent or change project folders. It | |
| 220 | keeps the machine identity and pairing. Leaving the first folder answer blank | |
| 221 | keeps existing folders; entering `-` clears them. A reinstall needs the existing | |
| 222 | pairing to remain active. Unlink first, then remove the saved `agent.json` if | |
| 223 | you want a new pairing or a different dashboard account. | |
| 224 | ||
| 225 | The 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 | ||
| 234 | Linux honors `XDG_DATA_HOME` and `XDG_CONFIG_HOME`. Uninstall removes startup | |
| 235 | registration and stops the agent, preserving pairing and session files. Pairing | |
| 236 | is in `~/.config/agent-relay/agent.json` on macOS/Linux, or | |
| 237 | `%LOCALAPPDATA%\AgentRelay\config\agent.json` on Windows. | |
| 238 | ||
| 239 | The local client source remains in the sibling Agent Relay project identified | |
| 240 | by `dashboard/agent/source.json`. `tools/deploy.py` bundles it into each frozen | |
| 241 | release before computing its digest. For a direct dashboard build or local | |
| 242 | preview, run `pnpm --dir dashboard install --frozen-lockfile` and | |
| 243 | `python3 tools/build-agent.py` first. The generated `relay.mjs` is a deployment | |
| 244 | artifact and is not checked into this repository. |
service/copyparty/copyparty.conf+4-3| ... | ... | @@ -15,12 +15,13 @@ |
| 15 | 15 | |
| 16 | 16 | xff-src: lan |
| 17 | 17 | rproxy: 1 |
| 18 | auth-ord: pw,idp,ipu | |
| 18 | 19 | idp-h-usr: user-name |
| 19 | 20 | idp-h-grp: user-groups |
| 20 | 21 | 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 | |
| 24 | 25 | html-head: <link rel="stylesheet" href="/.static/copyparty.css"> |
| 25 | 26 | |
| 26 | 27 | [/] |
tools/build-agent.py created+29| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import argparse | |
| 3 | import json | |
| 4 | from pathlib import Path | |
| 5 | import subprocess | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 24 | if __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 | |
| 2 | import argparse | |
| 3 | import fcntl | |
| 4 | import json | |
| 5 | import os | |
| 6 | from pathlib import Path | |
| 7 | import pty | |
| 8 | import re | |
| 9 | import select | |
| 10 | import shlex | |
| 11 | import sqlite3 | |
| 12 | import subprocess | |
| 13 | import tempfile | |
| 14 | import termios | |
| 15 | import time | |
| 16 | import urllib.error | |
| 17 | import urllib.request | |
| 18 | import uuid | |
| 19 | ||
| 20 | ||
| 21 | def 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 | ||
| 174 | if __name__ == "__main__": | |
| 175 | main() |
tools/dashboard-auth-test.py created+196| ... | ... | @@ -0,0 +1,196 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import argparse | |
| 3 | import base64 | |
| 4 | import hashlib | |
| 5 | import http.client | |
| 6 | import json | |
| 7 | import os | |
| 8 | from pathlib import Path | |
| 9 | import socket | |
| 10 | import sqlite3 | |
| 11 | import subprocess | |
| 12 | import tempfile | |
| 13 | import time | |
| 14 | import uuid | |
| 15 | from cryptography.hazmat.primitives import hashes | |
| 16 | from cryptography.hazmat.primitives.asymmetric import ec | |
| 17 | from cryptography.hazmat.primitives.kdf.argon2 import Argon2id | |
| 18 | ||
| 19 | ||
| 20 | def b64(value): | |
| 21 | return base64.urlsafe_b64encode(value).decode().rstrip('=') | |
| 22 | ||
| 23 | ||
| 24 | def 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 | ||
| 36 | def 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 | ||
| 195 | if __name__ == '__main__': | |
| 196 | main() |
tools/dashboard-backup-test.py created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import importlib | |
| 3 | import json | |
| 4 | from pathlib import Path | |
| 5 | import sqlite3 | |
| 6 | import tempfile | |
| 7 | ||
| 8 | ||
| 9 | def 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 | ||
| 70 | if __name__ == '__main__': | |
| 71 | main() |
tools/dashboard-mcp-test.py+1-1| ... | ... | @@ -67,7 +67,7 @@ def main(): |
| 67 | 67 | request = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], |
| 68 | 68 | "code_challenge_method": "S256", "code_challenge": challenge, "resource": resource, "scope": scope, "state": marker} |
| 69 | 69 | _, 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/") | |
| 71 | 71 | path = "/api/mcp/consent/" + pending |
| 72 | 72 | details, _ = http(path, actor=actor) |
| 73 | 73 | assert details["client"] == client["client_name"] |
tools/dashboard-relay-test.py+1-1| ... | ... | @@ -306,7 +306,7 @@ def main(): |
| 306 | 306 | fields = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], "resource": resource, "scope": "sessions:read sessions:write offline_access", |
| 307 | 307 | "code_challenge_method": "S256", "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")} |
| 308 | 308 | _, 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/") | |
| 310 | 310 | consent_path = "/api/mcp/consent/" + request_id |
| 311 | 311 | details, _ = http(consent_path, actor=names[0]) |
| 312 | 312 | 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(): |
| 49 | 49 | marker = 'shale-link-' + uuid.uuid4().hex |
| 50 | 50 | accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')] |
| 51 | 51 | ids = [] |
| 52 | sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts} | |
| 52 | 53 | |
| 53 | 54 | class TLS(urllib.request.HTTPSHandler): |
| 54 | 55 | def https_open(self, request): |
| ... | ... | @@ -76,6 +77,7 @@ def main(): |
| 76 | 77 | _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method, |
| 77 | 78 | body=json.dumps(body).encode() if body is not None else None, |
| 78 | 79 | headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor, |
| 80 | 'Cookie': '__Host-snow-session=' + sessions[actor], | |
| 79 | 81 | 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status) |
| 80 | 82 | return json.loads(body) if body and status < 400 else body or None |
| 81 | 83 | |
| ... | ... | @@ -132,7 +134,7 @@ def main(): |
| 132 | 134 | assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', [])) |
| 133 | 135 | assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', [])) |
| 134 | 136 | 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') | |
| 136 | 138 | |
| 137 | 139 | def backend_session(value, status): |
| 138 | 140 | return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status) |
| ... | ... | @@ -171,7 +173,7 @@ def main(): |
| 171 | 173 | _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code', |
| 172 | 174 | 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256', |
| 173 | 175 | '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/') | |
| 175 | 177 | details = api(accounts[index][0], path='/api/mcp/consent/' + pending) |
| 176 | 178 | assert details['client'] == marker |
| 177 | 179 | api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403) |
| ... | ... | @@ -180,13 +182,13 @@ def main(): |
| 180 | 182 | def consent(client, index, repository, scope): |
| 181 | 183 | pending, verifier, details = pending_request(client, index, scope) |
| 182 | 184 | 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 | |
| 184 | 186 | if index == 0: |
| 185 | assert available == {'alpha', 'beta'}, details | |
| 187 | assert {'alpha', 'beta'} <= available, details | |
| 186 | 188 | path = '/api/mcp/consent/' + pending |
| 187 | 189 | api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']}) |
| 188 | 190 | 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]}) | |
| 190 | 192 | query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query) |
| 191 | 193 | assert query['state'] == [marker] |
| 192 | 194 | tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'], |
| ... | ... | @@ -245,6 +247,13 @@ def main(): |
| 245 | 247 | found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true') |
| 246 | 248 | assert len(found) == 1 |
| 247 | 249 | 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)) | |
| 248 | 257 | assert all(api(name)['shale'] is None for name, _ in accounts) |
| 249 | 258 | api(accounts[0][0], 'POST', '/api/mcp/shale', status=200) |
| 250 | 259 | old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect'] |
| ... | ... | @@ -288,7 +297,37 @@ def main(): |
| 288 | 297 | 'comment_issue', 'set_issue_status', 'set_issue_title'} |
| 289 | 298 | assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools) |
| 290 | 299 | 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 | ||
| 291 | 328 | 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} | |
| 292 | 331 | assert not call(read, 'list_issues', {'repository': 'alpha'})['issues'] |
| 293 | 332 | for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'), |
| 294 | 333 | (write, 'alpha/../foreign'), (write, 'https://other.invalid')]: |
| ... | ... | @@ -392,6 +431,7 @@ def main(): |
| 392 | 431 | 'native_issue_labels_read': True, |
| 393 | 432 | 'committed_write_lost_response_reported_without_replay': True, |
| 394 | 433 | '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, | |
| 395 | 435 | 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True} |
| 396 | 436 | finally: |
| 397 | 437 | keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1) |
| ... | ... | @@ -408,6 +448,12 @@ def main(): |
| 408 | 448 | keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE') |
| 409 | 449 | except Exception as error: |
| 410 | 450 | 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,)) | |
| 411 | 457 | if cleanup_errors: |
| 412 | 458 | raise cleanup_errors[0] |
| 413 | 459 | result['owned_users_and_credentials_removed'] = True |
tools/data.py+47-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | import argparse |
| 3 | from contextlib import contextmanager | |
| 3 | from contextlib import closing, contextmanager | |
| 4 | 4 | from datetime import datetime, timezone |
| 5 | 5 | import hashlib |
| 6 | 6 | import json |
| ... | ... | @@ -9,6 +9,7 @@ from pathlib import Path |
| 9 | 9 | import re |
| 10 | 10 | import secrets |
| 11 | 11 | import shutil |
| 12 | import sqlite3 | |
| 12 | 13 | import subprocess |
| 13 | 14 | import time |
| 14 | 15 | |
| ... | ... | @@ -140,6 +141,28 @@ def cloned_postgres(snapshot, clone, mountpoint): |
| 140 | 141 | run("zfs", "destroy", clone) |
| 141 | 142 | |
| 142 | 143 | |
| 144 | def 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 | ||
| 143 | 166 | def backup(from_release, to_release): |
| 144 | 167 | if not RELEASE_ID.fullmatch(from_release) or not RELEASE_ID.fullmatch(to_release): |
| 145 | 168 | raise ValueError("Invalid release ID") |
| ... | ... | @@ -177,6 +200,8 @@ def backup(from_release, to_release): |
| 177 | 200 | snapshots = [f"{dataset}@{snapshot}" for dataset in sorted(datasets)] |
| 178 | 201 | created = False |
| 179 | 202 | try: |
| 203 | if (STATE / "dashboard").is_dir(): | |
| 204 | manifest["services"]["dashboard"] = {"files": backup_dashboard(directory / "dashboard")} | |
| 180 | 205 | if snapshots: |
| 181 | 206 | run("zfs", "snapshot", *snapshots) |
| 182 | 207 | created = True |
| ... | ... | @@ -231,6 +256,27 @@ def restore(backup_id, service): |
| 231 | 256 | if service == "postgres": |
| 232 | 257 | raise ValueError("Postgres serves multiple services; restore a specific database owner") |
| 233 | 258 | 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 | |
| 234 | 280 | dataset = entry.get("dataset") |
| 235 | 281 | if dataset and dataset_for(service) != dataset: |
| 236 | 282 | raise ValueError("Service dataset changed since backup") |
tools/deploy.py+3| ... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 | import argparse |
| 3 | 3 | import json |
| 4 | 4 | import os |
| 5 | from importlib import import_module | |
| 5 | 6 | from pathlib import Path |
| 6 | 7 | import re |
| 7 | 8 | import shlex |
| ... | ... | @@ -113,6 +114,8 @@ def upload(main=False): |
| 113 | 114 | shutil.copytree(origin, destination, symlinks=True) |
| 114 | 115 | else: |
| 115 | 116 | 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") | |
| 116 | 119 | digest = tree_digest(snapshot) |
| 117 | 120 | release = digest[:16] |
| 118 | 121 | remote_release = REMOTE / "releases" / release |
tools/import-dashboard-auth.py created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import argparse | |
| 3 | import hashlib | |
| 4 | import json | |
| 5 | import os | |
| 6 | from pathlib import Path | |
| 7 | import subprocess | |
| 8 | ||
| 9 | ||
| 10 | parser = argparse.ArgumentParser(description="Copy Keycloak accounts into a private dashboard import") | |
| 11 | parser.add_argument("--host", required=True) | |
| 12 | parser.add_argument("--output", required=True, type=Path) | |
| 13 | args = parser.parse_args() | |
| 14 | if args.output.exists(): | |
| 15 | parser.error("output already exists") | |
| 16 | ||
| 17 | remote = r''' | |
| 18 | import json, subprocess, urllib.request | |
| 19 | request = 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()}) | |
| 20 | allocations = [a["ID"] for a in json.load(urllib.request.urlopen(request)) if a["ClientStatus"] == "running" and a["DesiredStatus"] == "run"] | |
| 21 | assert len(allocations) == 1 | |
| 22 | containers = [line.split()[0] for line in subprocess.check_output(["podman", "ps", "--format", "{{.ID}} {{.Names}}"], text=True).splitlines() if line.split()[1].endswith(allocations[0])] | |
| 23 | assert len(containers) == 1 | |
| 24 | sql = """ | |
| 25 | BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY; | |
| 26 | SELECT 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 | ); | |
| 39 | COMMIT; | |
| 40 | """ | |
| 41 | result = 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) | |
| 42 | print(next(line for line in result.stdout.splitlines() if line.startswith('{'))) | |
| 43 | ''' | |
| 44 | result = subprocess.run(["ssh", "-o", "BatchMode=yes", args.host, "python3", "-"], input=remote, text=True, capture_output=True, check=True) | |
| 45 | data = json.loads(result.stdout) | |
| 46 | if not data["users"]: | |
| 47 | raise ValueError("source realm has no accounts") | |
| 48 | content = (json.dumps(data, separators=(",", ":")) + "\n").encode() | |
| 49 | args.output.parent.mkdir(mode=0o700, parents=True, exist_ok=True) | |
| 50 | with 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()) | |
| 54 | counts = {} | |
| 55 | for user in data["users"]: | |
| 56 | for credential in user["credentials"]: | |
| 57 | kind = credential["type"] | |
| 58 | counts[kind] = counts.get(kind, 0) + 1 | |
| 59 | print(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): |
| 50 | 50 | dashboard_proof = file.read().strip() |
| 51 | 51 | if not re.fullmatch(r"[0-9a-fA-F]{64}", dashboard_proof): |
| 52 | 52 | 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", " }"] | |
| 53 | 57 | routes = {} |
| 54 | 58 | internal_services = {} |
| 55 | 59 | auth_upstreams = set() |
| ... | ... | @@ -130,7 +134,11 @@ def render(token): |
| 130 | 134 | service = next(iter(route["services"])) |
| 131 | 135 | if not re.fullmatch(r"[a-z][a-z0-9-]*", service): |
| 132 | 136 | raise ValueError(f"invalid service name: {service}") |
| 137 | if service == "keycloak" and host == auth_host: | |
| 138 | lines += webauthn | |
| 133 | 139 | if service == "shale": |
| 140 | lines += [" @userscript path_regexp userscript ^/userscripts/discord-pluralkit-predict(/.*)?$", | |
| 141 | " redir @userscript /discord-pluralkit-predict{re.userscript.1}?{query} 308"] | |
| 134 | 142 | port = int(os.environ["STUDIO_DASHBOARD_PORT"]) |
| 135 | 143 | if not 1 <= port <= 65535: |
| 136 | 144 | raise ValueError("invalid dashboard port for Shale linking") |
| ... | ... | @@ -203,11 +211,19 @@ def render(token): |
| 203 | 211 | *proxy(upstreams, " "), " }", |
| 204 | 212 | " handle_response {", " respond 403", " }", " }", " }", "}", |
| 205 | 213 | ] |
| 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 {"] | |
| 209 | 224 | 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 []), | |
| 211 | 227 | " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}", |
| 212 | 228 | " @authenticated status 2xx", " handle_response @authenticated {"] |
| 213 | 229 | lines += [f" request_header {name} {{rp.header.{source}}}" for name, source in headers.items()] |
| ... | ... | @@ -227,6 +243,11 @@ def render(token): |
| 227 | 243 | *proxy(upstreams, " "), " }", "}"] |
| 228 | 244 | else: |
| 229 | 245 | 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", " }", "}"] | |
| 230 | 251 | traces = next((route for route in routes.values() if "victoria-traces" in route["services"]), None) |
| 231 | 252 | if traces: |
| 232 | 253 | lines += ["http://127.0.0.1:10428 {", " bind 127.0.0.1", |
| ... | ... | @@ -241,30 +262,10 @@ def render(token): |
| 241 | 262 | if dashboard_host.endswith(".test"): |
| 242 | 263 | lines.append(" tls internal") |
| 243 | 264 | 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}", " "), " }", "}"] | |
| 268 | 269 | internal_port = os.environ.get("STUDIO_INTERNAL_PORT") |
| 269 | 270 | if internal_port is not None: |
| 270 | 271 | internal_host = "dashboard.internal." + os.environ["STUDIO_DOMAIN"] |