| 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 +3,4 @@ dist/ |
| 3 | .cache/ | 3 | .cache/ |
| 4 | data/ | 4 | data/ |
| 5 | target/ | 5 | target/ |
| 6 | agent/relay.mjs |
dashboard/Cargo.lock+443-8| ... | @@ -20,6 +20,57 @@ dependencies = [ | ... | @@ -20,6 +20,57 @@ dependencies = [ |
| 20 | "libc", | 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 | [[package]] | 74 | [[package]] |
| 24 | name = "async-trait" | 75 | name = "async-trait" |
| 25 | version = "0.1.92" | 76 | version = "0.1.92" |
| ... | @@ -99,6 +150,12 @@ dependencies = [ | ... | @@ -99,6 +150,12 @@ dependencies = [ |
| 99 | "tracing", | 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 | [[package]] | 159 | [[package]] |
| 103 | name = "base64" | 160 | name = "base64" |
| 104 | version = "0.22.1" | 161 | version = "0.22.1" |
| ... | @@ -111,12 +168,38 @@ version = "0.23.1" | ... | @@ -111,12 +168,38 @@ version = "0.23.1" |
| 111 | source = "registry+https://github.com/rust-lang/crates.io-index" | 168 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 112 | checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" | 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 | [[package]] | 188 | [[package]] |
| 115 | name = "bitflags" | 189 | name = "bitflags" |
| 116 | version = "2.13.2" | 190 | version = "2.13.2" |
| 117 | source = "registry+https://github.com/rust-lang/crates.io-index" | 191 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 118 | checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" | 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 | [[package]] | 203 | [[package]] |
| 121 | name = "block-buffer" | 204 | name = "block-buffer" |
| 122 | version = "0.10.4" | 205 | version = "0.10.4" |
| ... | @@ -225,6 +308,12 @@ dependencies = [ | ... | @@ -225,6 +308,12 @@ dependencies = [ |
| 225 | "libc", | 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 | [[package]] | 317 | [[package]] |
| 229 | name = "crypto-common" | 318 | name = "crypto-common" |
| 230 | version = "0.1.7" | 319 | version = "0.1.7" |
| ... | @@ -264,6 +353,26 @@ version = "2.11.1" | ... | @@ -264,6 +353,26 @@ version = "2.11.1" |
| 264 | source = "registry+https://github.com/rust-lang/crates.io-index" | 353 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 265 | checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" | 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 | [[package]] | 376 | [[package]] |
| 268 | name = "derive_more" | 377 | name = "derive_more" |
| 269 | version = "2.1.1" | 378 | version = "2.1.1" |
| ... | @@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | ... | @@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" |
| 293 | dependencies = [ | 402 | dependencies = [ |
| 294 | "block-buffer", | 403 | "block-buffer", |
| 295 | "crypto-common", | 404 | "crypto-common", |
| 405 | "subtle", | ||
| 296 | ] | 406 | ] |
| 297 | 407 | ||
| 298 | [[package]] | 408 | [[package]] |
| ... | @@ -393,6 +503,21 @@ version = "0.1.5" | ... | @@ -393,6 +503,21 @@ version = "0.1.5" |
| 393 | source = "registry+https://github.com/rust-lang/crates.io-index" | 503 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 394 | checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" | 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 | [[package]] | 521 | [[package]] |
| 397 | name = "form_urlencoded" | 522 | name = "form_urlencoded" |
| 398 | version = "1.2.2" | 523 | version = "1.2.2" |
| ... | @@ -552,6 +677,17 @@ dependencies = [ | ... | @@ -552,6 +677,17 @@ dependencies = [ |
| 552 | "regex-syntax", | 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 | [[package]] | 691 | [[package]] |
| 556 | name = "hashbrown" | 692 | name = "hashbrown" |
| 557 | version = "0.15.5" | 693 | version = "0.15.5" |
| ... | @@ -576,10 +712,17 @@ dependencies = [ | ... | @@ -576,10 +712,17 @@ dependencies = [ |
| 576 | "hashbrown 0.15.5", | 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 | [[package]] | 721 | [[package]] |
| 580 | name = "home-dashboard" | 722 | name = "home-dashboard" |
| 581 | version = "0.1.0" | 723 | version = "0.1.0" |
| 582 | dependencies = [ | 724 | dependencies = [ |
| 725 | "argon2", | ||
| 583 | "axum", | 726 | "axum", |
| 584 | "base64 0.22.1", | 727 | "base64 0.22.1", |
| 585 | "bytes", | 728 | "bytes", |
| ... | @@ -592,6 +735,8 @@ dependencies = [ | ... | @@ -592,6 +735,8 @@ dependencies = [ |
| 592 | "rmcp", | 735 | "rmcp", |
| 593 | "rusqlite", | 736 | "rusqlite", |
| 594 | "scraper", | 737 | "scraper", |
| 738 | "serde", | ||
| 739 | "serde_cbor_2", | ||
| 595 | "serde_json", | 740 | "serde_json", |
| 596 | "sha1", | 741 | "sha1", |
| 597 | "sha2", | 742 | "sha2", |
| ... | @@ -602,6 +747,7 @@ dependencies = [ | ... | @@ -602,6 +747,7 @@ dependencies = [ |
| 602 | "url", | 747 | "url", |
| 603 | "uuid", | 748 | "uuid", |
| 604 | "walkdir", | 749 | "walkdir", |
| 750 | "webauthn-rs", | ||
| 605 | ] | 751 | ] |
| 606 | 752 | ||
| 607 | [[package]] | 753 | [[package]] |
| ... | @@ -889,6 +1035,12 @@ dependencies = [ | ... | @@ -889,6 +1035,12 @@ dependencies = [ |
| 889 | "wasm-bindgen", | 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 | [[package]] | 1044 | [[package]] |
| 893 | name = "libc" | 1045 | name = "libc" |
| 894 | version = "0.2.189" | 1046 | version = "0.2.189" |
| ... | @@ -972,6 +1124,12 @@ dependencies = [ | ... | @@ -972,6 +1124,12 @@ dependencies = [ |
| 972 | "unicase", | 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 | [[package]] | 1133 | [[package]] |
| 976 | name = "mio" | 1134 | name = "mio" |
| 977 | version = "1.2.3" | 1135 | version = "1.2.3" |
| ... | @@ -1012,6 +1170,41 @@ version = "1.0.6" | ... | @@ -1012,6 +1170,41 @@ version = "1.0.6" |
| 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1170 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1013 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" | 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 | [[package]] | 1208 | [[package]] |
| 1016 | name = "num-traits" | 1209 | name = "num-traits" |
| 1017 | version = "0.2.19" | 1210 | version = "0.2.19" |
| ... | @@ -1021,12 +1214,58 @@ dependencies = [ | ... | @@ -1021,12 +1214,58 @@ dependencies = [ |
| 1021 | "autocfg", | 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 | [[package]] | 1226 | [[package]] |
| 1025 | name = "once_cell" | 1227 | name = "once_cell" |
| 1026 | version = "1.21.4" | 1228 | version = "1.21.4" |
| 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1229 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1028 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | 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 | [[package]] | 1269 | [[package]] |
| 1031 | name = "parking_lot" | 1270 | name = "parking_lot" |
| 1032 | version = "0.12.5" | 1271 | version = "0.12.5" |
| ... | @@ -1050,6 +1289,23 @@ dependencies = [ | ... | @@ -1050,6 +1289,23 @@ dependencies = [ |
| 1050 | "windows-link", | 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 | [[package]] | 1309 | [[package]] |
| 1054 | name = "pastey" | 1310 | name = "pastey" |
| 1055 | version = "0.2.3" | 1311 | version = "0.2.3" |
| ... | @@ -1136,6 +1392,12 @@ dependencies = [ | ... | @@ -1136,6 +1392,12 @@ dependencies = [ |
| 1136 | "zerovec", | 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 | [[package]] | 1401 | [[package]] |
| 1140 | name = "ppv-lite86" | 1402 | name = "ppv-lite86" |
| 1141 | version = "0.2.21" | 1403 | version = "0.2.21" |
| ... | @@ -1174,7 +1436,7 @@ dependencies = [ | ... | @@ -1174,7 +1436,7 @@ dependencies = [ |
| 1174 | "rustc-hash", | 1436 | "rustc-hash", |
| 1175 | "rustls", | 1437 | "rustls", |
| 1176 | "socket2", | 1438 | "socket2", |
| 1177 | "thiserror", | 1439 | "thiserror 2.0.21", |
| 1178 | "tokio", | 1440 | "tokio", |
| 1179 | "tracing", | 1441 | "tracing", |
| 1180 | "web-time", | 1442 | "web-time", |
| ... | @@ -1196,7 +1458,7 @@ dependencies = [ | ... | @@ -1196,7 +1458,7 @@ dependencies = [ |
| 1196 | "rustls", | 1458 | "rustls", |
| 1197 | "rustls-pki-types", | 1459 | "rustls-pki-types", |
| 1198 | "slab", | 1460 | "slab", |
| 1199 | "thiserror", | 1461 | "thiserror 2.0.21", |
| 1200 | "tinyvec", | 1462 | "tinyvec", |
| 1201 | "tracing", | 1463 | "tracing", |
| 1202 | "web-time", | 1464 | "web-time", |
| ... | @@ -1268,6 +1530,12 @@ dependencies = [ | ... | @@ -1268,6 +1530,12 @@ dependencies = [ |
| 1268 | "rand_core 0.9.5", | 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 | [[package]] | 1539 | [[package]] |
| 1272 | name = "rand_core" | 1540 | name = "rand_core" |
| 1273 | version = "0.9.5" | 1541 | version = "0.9.5" |
| ... | @@ -1419,14 +1687,14 @@ dependencies = [ | ... | @@ -1419,14 +1687,14 @@ dependencies = [ |
| 1419 | "http-body", | 1687 | "http-body", |
| 1420 | "http-body-util", | 1688 | "http-body-util", |
| 1421 | "indexmap", | 1689 | "indexmap", |
| 1422 | "pastey", | 1690 | "pastey 0.2.3", |
| 1423 | "pin-project-lite", | 1691 | "pin-project-lite", |
| 1424 | "rand 0.10.3", | 1692 | "rand 0.10.3", |
| 1425 | "schemars", | 1693 | "schemars", |
| 1426 | "serde", | 1694 | "serde", |
| 1427 | "serde_json", | 1695 | "serde_json", |
| 1428 | "sse-stream", | 1696 | "sse-stream", |
| 1429 | "thiserror", | 1697 | "thiserror 2.0.21", |
| 1430 | "tokio", | 1698 | "tokio", |
| 1431 | "tokio-stream", | 1699 | "tokio-stream", |
| 1432 | "tokio-util", | 1700 | "tokio-util", |
| ... | @@ -1464,6 +1732,15 @@ dependencies = [ | ... | @@ -1464,6 +1732,15 @@ dependencies = [ |
| 1464 | "semver", | 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 | [[package]] | 1744 | [[package]] |
| 1468 | name = "rustls" | 1745 | name = "rustls" |
| 1469 | version = "0.23.45" | 1746 | version = "0.23.45" |
| ... | @@ -1601,6 +1878,16 @@ dependencies = [ | ... | @@ -1601,6 +1878,16 @@ dependencies = [ |
| 1601 | "serde_derive", | 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 | [[package]] | 1891 | [[package]] |
| 1605 | name = "serde_core" | 1892 | name = "serde_core" |
| 1606 | version = "1.0.229" | 1893 | version = "1.0.229" |
| ... | @@ -1835,6 +2122,17 @@ dependencies = [ | ... | @@ -1835,6 +2122,17 @@ dependencies = [ |
| 1835 | "futures-core", | 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 | [[package]] | 2136 | [[package]] |
| 1839 | name = "synstructure" | 2137 | name = "synstructure" |
| 1840 | version = "0.14.0" | 2138 | version = "0.14.0" |
| ... | @@ -1855,13 +2153,33 @@ dependencies = [ | ... | @@ -1855,13 +2153,33 @@ dependencies = [ |
| 1855 | "new_debug_unreachable", | 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 | [[package]] | 2165 | [[package]] |
| 1859 | name = "thiserror" | 2166 | name = "thiserror" |
| 1860 | version = "2.0.21" | 2167 | version = "2.0.21" |
| 1861 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2168 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1862 | checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" | 2169 | checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" |
| 1863 | dependencies = [ | 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 | [[package]] | 2185 | [[package]] |
| ... | @@ -1875,6 +2193,36 @@ dependencies = [ | ... | @@ -1875,6 +2193,36 @@ dependencies = [ |
| 1875 | "syn 3.0.6", | 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 | [[package]] | 2226 | [[package]] |
| 1879 | name = "tinystr" | 2227 | name = "tinystr" |
| 1880 | version = "0.8.4" | 2228 | version = "0.8.4" |
| ... | @@ -2073,7 +2421,7 @@ dependencies = [ | ... | @@ -2073,7 +2421,7 @@ dependencies = [ |
| 2073 | "log", | 2421 | "log", |
| 2074 | "rand 0.9.5", | 2422 | "rand 0.9.5", |
| 2075 | "sha1", | 2423 | "sha1", |
| 2076 | "thiserror", | 2424 | "thiserror 2.0.21", |
| 2077 | ] | 2425 | ] |
| 2078 | 2426 | ||
| 2079 | [[package]] | 2427 | [[package]] |
| ... | @@ -2110,6 +2458,7 @@ dependencies = [ | ... | @@ -2110,6 +2458,7 @@ dependencies = [ |
| 2110 | "idna", | 2458 | "idna", |
| 2111 | "percent-encoding", | 2459 | "percent-encoding", |
| 2112 | "serde", | 2460 | "serde", |
| 2461 | "serde_derive", | ||
| 2113 | ] | 2462 | ] |
| 2114 | 2463 | ||
| 2115 | [[package]] | 2464 | [[package]] |
| ... | @@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" | ... | @@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" |
| 2126 | dependencies = [ | 2475 | dependencies = [ |
| 2127 | "getrandom 0.4.3", | 2476 | "getrandom 0.4.3", |
| 2128 | "js-sys", | 2477 | "js-sys", |
| 2478 | "serde_core", | ||
| 2129 | "wasm-bindgen", | 2479 | "wasm-bindgen", |
| 2130 | ] | 2480 | ] |
| 2131 | 2481 | ||
| ... | @@ -2263,6 +2613,74 @@ dependencies = [ | ... | @@ -2263,6 +2613,74 @@ dependencies = [ |
| 2263 | "string_cache_codegen", | 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 | [[package]] | 2684 | [[package]] |
| 2267 | name = "webpki-roots" | 2685 | name = "webpki-roots" |
| 2268 | version = "1.0.9" | 2686 | version = "1.0.9" |
| ... | @@ -2434,6 +2852,23 @@ version = "0.6.4" | ... | @@ -2434,6 +2852,23 @@ version = "0.6.4" |
| 2434 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2852 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2435 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | 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 | [[package]] | 2872 | [[package]] |
| 2438 | name = "yoke" | 2873 | name = "yoke" |
| 2439 | version = "0.8.3" | 2874 | version = "0.8.3" |
| ... | @@ -2454,7 +2889,7 @@ dependencies = [ | ... | @@ -2454,7 +2889,7 @@ dependencies = [ |
| 2454 | "proc-macro2", | 2889 | "proc-macro2", |
| 2455 | "quote", | 2890 | "quote", |
| 2456 | "syn 3.0.6", | 2891 | "syn 3.0.6", |
| 2457 | "synstructure", | 2892 | "synstructure 0.14.0", |
| 2458 | ] | 2893 | ] |
| 2459 | 2894 | ||
| 2460 | [[package]] | 2895 | [[package]] |
| ... | @@ -2495,7 +2930,7 @@ dependencies = [ | ... | @@ -2495,7 +2930,7 @@ dependencies = [ |
| 2495 | "proc-macro2", | 2930 | "proc-macro2", |
| 2496 | "quote", | 2931 | "quote", |
| 2497 | "syn 3.0.6", | 2932 | "syn 3.0.6", |
| 2498 | "synstructure", | 2933 | "synstructure 0.14.0", |
| 2499 | ] | 2934 | ] |
| 2500 | 2935 | ||
| 2501 | [[package]] | 2936 | [[package]] |
dashboard/Cargo.toml+4| ... | @@ -4,6 +4,7 @@ version = "0.1.0" | ... | @@ -4,6 +4,7 @@ version = "0.1.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [dependencies] |
| 7 | argon2 = "0.5" | ||
| 7 | axum = { version = "0.8.9", features = ["multipart", "ws"] } | 8 | axum = { version = "0.8.9", features = ["multipart", "ws"] } |
| 8 | base64 = "0.22" | 9 | base64 = "0.22" |
| 9 | bytes = "1" | 10 | bytes = "1" |
| ... | @@ -15,6 +16,8 @@ regex = "1" | ... | @@ -15,6 +16,8 @@ regex = "1" |
| 15 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } | 16 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } |
| 16 | rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } | 17 | rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } |
| 17 | rusqlite = { version = "0.37", features = ["bundled"] } | 18 | rusqlite = { version = "0.37", features = ["bundled"] } |
| 19 | serde = { version = "1", features = ["derive"] } | ||
| 20 | serde_cbor_2 = "0.13" | ||
| 18 | scraper = { version = "0.27", default-features = false } | 21 | scraper = { version = "0.27", default-features = false } |
| 19 | serde_json = "1" | 22 | serde_json = "1" |
| 20 | sha1 = "0.10" | 23 | sha1 = "0.10" |
| ... | @@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] } | ... | @@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] } |
| 26 | url = "2" | 29 | url = "2" |
| 27 | uuid = { version = "1", features = ["v4"] } | 30 | uuid = { version = "1", features = ["v4"] } |
| 28 | walkdir = "2" | 31 | walkdir = "2" |
| 32 | webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } | ||
| 29 | 33 | ||
| 30 | [profile.release] | 34 | [profile.release] |
| 31 | lto = "thin" | 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,6 +15,7 @@ |
| 15 | "@solidjs/router": "^1.0.0", | 15 | "@solidjs/router": "^1.0.0", |
| 16 | "@types/node": "^26.6.2", | 16 | "@types/node": "^26.6.2", |
| 17 | "concurrently": "^10.0.5", | 17 | "concurrently": "^10.0.5", |
| 18 | "esbuild": "0.28.2", | ||
| 18 | "lucide-solid": "^1.48.0", | 19 | "lucide-solid": "^1.48.0", |
| 19 | "solid-js": "^1.9.15", | 20 | "solid-js": "^1.9.15", |
| 20 | "typescript": "^7.0.2", | 21 | "typescript": "^7.0.2", |
dashboard/pnpm-lock.yaml+3-1| ... | @@ -21,6 +21,9 @@ importers: | ... | @@ -21,6 +21,9 @@ importers: |
| 21 | concurrently: | 21 | concurrently: |
| 22 | specifier: ^10.0.5 | 22 | specifier: ^10.0.5 |
| 23 | version: 10.0.5 | 23 | version: 10.0.5 |
| 24 | esbuild: | ||
| 25 | specifier: 0.28.2 | ||
| 26 | version: 0.28.2 | ||
| 24 | lucide-solid: | 27 | lucide-solid: |
| 25 | specifier: ^1.48.0 | 28 | specifier: ^1.48.0 |
| 26 | version: 1.48.0(solid-js@1.9.15) | 29 | version: 1.48.0(solid-js@1.9.15) |
| ... | @@ -1379,7 +1382,6 @@ snapshots: | ... | @@ -1379,7 +1382,6 @@ snapshots: |
| 1379 | '@esbuild/win32-arm64': 0.28.2 | 1382 | '@esbuild/win32-arm64': 0.28.2 |
| 1380 | '@esbuild/win32-ia32': 0.28.2 | 1383 | '@esbuild/win32-ia32': 0.28.2 |
| 1381 | '@esbuild/win32-x64': 0.28.2 | 1384 | '@esbuild/win32-x64': 0.28.2 |
| 1382 | optional: true | ||
| 1383 | 1385 | ||
| 1384 | escalade@3.2.0: {} | 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,34 +51,6 @@ impl Cache { |
| 51 | Ok(entry) | 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 | pub fn invalidate(&self, key: &str) { | 54 | pub fn invalidate(&self, key: &str) { |
| 83 | self.0.lock().unwrap().remove(key); | 55 | self.0.lock().unwrap().remove(key); |
| 84 | } | 56 | } |
| ... | @@ -180,51 +152,6 @@ mod tests { | ... | @@ -180,51 +152,6 @@ mod tests { |
| 180 | use super::*; | 152 | use super::*; |
| 181 | use std::sync::atomic::{AtomicUsize, Ordering}; | 153 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 182 | #[tokio::test] | 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 | async fn disconnecting_reader_does_not_cancel_shared_load() { | 155 | async fn disconnecting_reader_does_not_cancel_shared_load() { |
| 229 | let cache = Arc::new(Cache::default()); | 156 | let cache = Arc::new(Cache::default()); |
| 230 | let started = Arc::new(tokio::sync::Notify::new()); | 157 | let started = Arc::new(tokio::sync::Notify::new()); |
dashboard/src/core.rs+7-2| ... | @@ -839,7 +839,9 @@ mod tests { | ... | @@ -839,7 +839,9 @@ mod tests { |
| 839 | #[tokio::test] | 839 | #[tokio::test] |
| 840 | async fn launcher_excludes_directories_and_grouped_definitions_before_import() { | 840 | async fn launcher_excludes_directories_and_grouped_definitions_before_import() { |
| 841 | let root = std::env::temp_dir().join(format!("studio-launcher-{}", uuid::Uuid::new_v4())); | 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 | for directory in ["retired", "personal"] { | 845 | for directory in ["retired", "personal"] { |
| 844 | tokio::fs::create_dir_all(root.join("service").join(directory)) | 846 | tokio::fs::create_dir_all(root.join("service").join(directory)) |
| 845 | .await | 847 | .await |
| ... | @@ -864,7 +866,10 @@ mod tests { | ... | @@ -864,7 +866,10 @@ mod tests { |
| 864 | assert!(module.contains("/personal/service.pkl")); | 866 | assert!(module.contains("/personal/service.pkl")); |
| 865 | assert!(module.contains("/personal/fixture.pkl")); | 867 | assert!(module.contains("/personal/fixture.pkl")); |
| 866 | assert_eq!( | 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 | 2, | 873 | 2, |
| 869 | ); | 874 | ); |
| 870 | tokio::fs::remove_dir_all(root).await.unwrap(); | 875 | tokio::fs::remove_dir_all(root).await.unwrap(); |
dashboard/src/main.rs+113-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | mod apps; | 1 | mod apps; |
| 2 | mod auth; | ||
| 2 | mod cache; | 3 | mod cache; |
| 3 | mod core; | 4 | mod core; |
| 4 | mod deploys; | 5 | mod deploys; |
| ... | @@ -82,6 +83,7 @@ impl Document { | ... | @@ -82,6 +83,7 @@ impl Document { |
| 82 | } | 83 | } |
| 83 | 84 | ||
| 84 | struct App { | 85 | struct App { |
| 86 | auth: auth::Store, | ||
| 85 | mcp: mcp::Store, | 87 | mcp: mcp::Store, |
| 86 | relay: relay::Broker, | 88 | relay: relay::Broker, |
| 87 | shale: shale::Backend, | 89 | shale: shale::Backend, |
| ... | @@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { | ... | @@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 388 | "STUDIO_PUBLIC_ORIGIN", | 390 | "STUDIO_PUBLIC_ORIGIN", |
| 389 | &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")), | 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 | let app = Arc::new(App { | 431 | let app = Arc::new(App { |
| 432 | auth, | ||
| 392 | mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin) | 433 | mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin) |
| 393 | .map_err(|error| std::io::Error::other(error.message))?, | 434 | .map_err(|error| std::io::Error::other(error.message))?, |
| 394 | relay: relay::Broker::default(), | 435 | relay: relay::Broker::default(), |
| ... | @@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { | ... | @@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 437 | let dist = env("STUDIO_WEB_DIR", "dist"); | 478 | let dist = env("STUDIO_WEB_DIR", "dist"); |
| 438 | let router = Router::new() | 479 | let router = Router::new() |
| 439 | .route("/api/{*path}", any(api)) | 480 | .route("/api/{*path}", any(api)) |
| 481 | .route("/auth/{*path}", any(auth::route)) | ||
| 440 | .route("/oauth/{*path}", any(mcp::oauth)) | 482 | .route("/oauth/{*path}", any(mcp::oauth)) |
| 441 | .route("/.well-known/{*path}", any(mcp::oauth)) | 483 | .route("/.well-known/{*path}", any(mcp::oauth)) |
| 442 | .nest_service("/assets", ServeDir::new(format!("{dist}/assets"))) | 484 | .nest_service("/assets", ServeDir::new(format!("{dist}/assets"))) |
| 443 | .with_state(app.clone()) | 485 | .with_state(app.clone()) |
| 444 | .merge(observability::router(app.clone())) | 486 | .merge(observability::router(app.clone())) |
| 445 | .merge(shale::router(app.clone())) | 487 | .merge(shale::router(app.clone())) |
| 446 | .merge(relay::router(app)) | 488 | .merge(relay::router(app.clone())) |
| 447 | .fallback_service( | 489 | .fallback_service( |
| 448 | ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))), | 490 | ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))), |
| 449 | ) | 491 | ) |
| 450 | .layer(axum::middleware::from_fn( | 492 | .layer(axum::middleware::from_fn( |
| 451 | move |mut request: Request, next: axum::middleware::Next| { | 493 | move |mut request: Request, next: axum::middleware::Next| { |
| 452 | let proof = proof.clone(); | 494 | let proof = proof.clone(); |
| 495 | let app = app.clone(); | ||
| 453 | async move { | 496 | async move { |
| 454 | if mcp::public(request.uri().path()) { | 497 | if mcp::public(request.uri().path()) { |
| 455 | request.headers_mut().remove("Studio-Proxy-Token"); | 498 | request.headers_mut().remove("Studio-Proxy-Token"); |
| ... | @@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { | ... | @@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 467 | } | 510 | } |
| 468 | request.headers_mut().remove("Studio-Proxy-Token"); | 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 | let document = !asset && !request.uri().path().starts_with("/api/"); | 579 | let document = !asset && !request.uri().path().starts_with("/api/"); |
| 472 | if document { | 580 | if document { |
| 473 | request.headers_mut().remove("if-modified-since"); | 581 | request.headers_mut().remove("if-modified-since"); |
| 474 | request.headers_mut().remove("if-none-match"); | 582 | request.headers_mut().remove("if-none-match"); |
| 475 | } | 583 | } |
| 476 | let mut response = next.run(request).await; | 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 | if asset && response.status().is_success() { | 588 | if asset && response.status().is_success() { |
| 478 | response.headers_mut().insert( | 589 | response.headers_mut().insert( |
| 479 | "cache-control", | 590 | "cache-control", |
dashboard/src/mcp.rs+143-47| ... | @@ -445,7 +445,7 @@ impl Store { | ... | @@ -445,7 +445,7 @@ impl Store { |
| 445 | tx.commit()?; | 445 | tx.commit()?; |
| 446 | return Ok(( | 446 | return Ok(( |
| 447 | StatusCode::FOUND, | 447 | StatusCode::FOUND, |
| 448 | [("location", format!("/mcp?request={}", encoded(&pending)))], | 448 | [("location", format!("/connect/{}", encoded(&pending)))], |
| 449 | ) | 449 | ) |
| 450 | .into_response()); | 450 | .into_response()); |
| 451 | } | 451 | } |
| ... | @@ -490,6 +490,16 @@ impl Store { | ... | @@ -490,6 +490,16 @@ impl Store { |
| 490 | 490 | ||
| 491 | #[derive(Clone)] | 491 | #[derive(Clone)] |
| 492 | pub(crate) struct Grant(pub Value); | 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 | pub fn router<H: rmcp::ServerHandler>( | 503 | pub fn router<H: rmcp::ServerHandler>( |
| 494 | app: Arc<App>, | 504 | app: Arc<App>, |
| 495 | catalog: &str, | 505 | catalog: &str, |
| ... | @@ -529,7 +539,9 @@ pub fn router<H: rmcp::ServerHandler>( | ... | @@ -529,7 +539,9 @@ pub fn router<H: rmcp::ServerHandler>( |
| 529 | return Error::new(403, "This origin cannot use the connector.") | 539 | return Error::new(403, "This origin cannot use the connector.") |
| 530 | .into_response(); | 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 | Ok(grant) => { | 545 | Ok(grant) => { |
| 534 | request.extensions_mut().insert(Grant(grant)); | 546 | request.extensions_mut().insert(Grant(grant)); |
| 535 | if let Some(value) = request.headers_mut().get_mut("authorization") { | 547 | if let Some(value) = request.headers_mut().get_mut("authorization") { |
| ... | @@ -554,10 +566,16 @@ pub fn router<H: rmcp::ServerHandler>( | ... | @@ -554,10 +566,16 @@ pub fn router<H: rmcp::ServerHandler>( |
| 554 | 566 | ||
| 555 | pub fn public(path: &str) -> bool { | 567 | pub fn public(path: &str) -> bool { |
| 556 | path.starts_with("/oauth/") | 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 | || path.starts_with("/.well-known/oauth-") | 572 | || path.starts_with("/.well-known/oauth-") |
| 559 | || path == "/pairing" | 573 | || path == "/pairing" |
| 560 | || path == "/agent/connect" | 574 | || path == "/agent/connect" |
| 575 | || matches!( | ||
| 576 | path, | ||
| 577 | "/agent/install.sh" | "/agent/install.ps1" | "/agent/setup.mjs" | "/agent/relay.mjs" | ||
| 578 | ) | ||
| 561 | || path.starts_with("/api/v1/") | 579 | || path.starts_with("/api/v1/") |
| 562 | } | 580 | } |
| 563 | pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response { | 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,19 +608,7 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response { |
| 590 | let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null}; | 608 | let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null}; |
| 591 | if path == "/oauth/token" && method == Method::POST { | 609 | if path == "/oauth/token" && method == Method::POST { |
| 592 | let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?; | 610 | let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?; |
| 593 | let id = string(&grant["user"]); | 611 | if !active_owner(&app, &grant)? { |
| 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") { | ||
| 606 | revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; | 612 | revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; |
| 607 | return Err(fail("invalid_grant")); | 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,6 +639,25 @@ pub async fn oauth(State(app): State<Arc<App>>, request: Request) -> Response { |
| 633 | response | 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 | pub async fn manage( | 661 | pub async fn manage( |
| 637 | app: Arc<App>, | 662 | app: Arc<App>, |
| 638 | method: &Method, | 663 | method: &Method, |
| ... | @@ -672,32 +697,66 @@ pub async fn manage( | ... | @@ -672,32 +697,66 @@ pub async fn manage( |
| 672 | .keep_alive(axum::response::sse::KeepAlive::default()) | 697 | .keep_alive(axum::response::sse::KeepAlive::default()) |
| 673 | .into_response()); | 698 | .into_response()); |
| 674 | } | 699 | } |
| 675 | let consent_resource = if let ["consent", id] = parts { | 700 | let consent_resource = if let ["connections", id] = parts |
| 676 | get( | 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 | &app.mcp.db.lock().unwrap(), | 710 | &app.mcp.db.lock().unwrap(), |
| 678 | &format!("pending:{}", hash(id)), | 711 | &format!("pending:{}", hash(id)), |
| 679 | )?["resource"] | 712 | )?; |
| 680 | .as_str() | 713 | if pending.is_null() { |
| 681 | .unwrap_or_default() | 714 | return Err(Error::new( |
| 682 | .to_owned() | 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 | } else { | 726 | } else { |
| 684 | String::new() | 727 | String::new() |
| 685 | }; | 728 | }; |
| 686 | let agent_consent = consent_resource == app.mcp.resource("agents"); | 729 | let agent_consent = consent_resource == app.mcp.resource("agents"); |
| 687 | let shale_consent = consent_resource == app.mcp.resource("shale"); | 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 | let mut linked = true; | 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 | relay::machines(&app.mcp.db.lock().unwrap(), owner_id)? | 740 | relay::machines(&app.mcp.db.lock().unwrap(), owner_id)? |
| 691 | .into_iter() | 741 | .into_iter() |
| 692 | .map(|m| json!({"id":m["id"],"name":m["name"]})) | 742 | .map(|m| json!({"id":m["id"],"name":m["name"]})) |
| 693 | .collect() | 743 | .collect() |
| 694 | } else if shale_consent && body["deny"] != true { | 744 | } else if shale_consent { |
| 695 | match shale::repositories(&app, owner_id).await { | 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 | Ok(repositories) => repositories, | 751 | Ok(repositories) => repositories, |
| 697 | Err(error) if error.status == 401 => { | 752 | Err(error) if error.status == 401 => { |
| 698 | linked = false; | 753 | linked = false; |
| 699 | Vec::new() | 754 | Vec::new() |
| 700 | } | 755 | } |
| 756 | Err(error) if method == Method::GET => { | ||
| 757 | resource_error = Some(error.message); | ||
| 758 | Vec::new() | ||
| 759 | } | ||
| 701 | Err(error) => return Err(error), | 760 | Err(error) => return Err(error), |
| 702 | } | 761 | } |
| 703 | } else if consent_resource == app.mcp.resource("observability") | 762 | } else if consent_resource == app.mcp.resource("observability") |
| ... | @@ -726,13 +785,14 @@ pub async fn manage( | ... | @@ -726,13 +785,14 @@ pub async fn manage( |
| 726 | let machines = relay::machines(&tx, owner_id)?; | 785 | let machines = relay::machines(&tx, owner_id)?; |
| 727 | let connections = grants.iter().map(|grant| { | 786 | let connections = grants.iter().map(|grant| { |
| 728 | let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()}; | 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(); | 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<_>>())}; |
| 730 | Ok(json!({"id":grant["id"],"name":name,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]})) | 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 | }).collect::<Result<Vec<_>>>()?; | 791 | }).collect::<Result<Vec<_>>>()?; |
| 732 | let shale = get(&tx, &format!("shale-session:{owner_id}"))?; | 792 | let shale = get(&tx, &format!("shale-session:{owner_id}"))?; |
| 733 | let catalogs: Vec<_> = CATALOGS | 793 | let catalogs: Vec<_> = CATALOGS |
| 734 | .iter() | 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 | .collect(); | 796 | .collect(); |
| 737 | 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"]})}}) | 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,29 +830,19 @@ pub async fn manage( |
| 770 | "UPDATE records SET value=? WHERE key=?", | 830 | "UPDATE records SET value=? WHERE key=?", |
| 771 | rusqlite::params![pending.to_string(), key], | 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 | } else { | 834 | } else { |
| 775 | if shale_consent | 835 | if shale_consent |
| 776 | && get(&tx, &format!("shale-session:{owner_id}"))?["origin"] | 836 | && (!linked |
| 777 | != app.shale.origin.as_str() | 837 | || get(&tx, &format!("shale-session:{owner_id}"))?["origin"] |
| 838 | != app.shale.origin.as_str()) | ||
| 778 | { | 839 | { |
| 779 | return Err(Error::new( | 840 | return Err(Error::new( |
| 780 | 401, | 841 | 401, |
| 781 | "Link your Shale account before allowing repository access.", | 842 | "Link your Shale account before allowing repository access.", |
| 782 | )); | 843 | )); |
| 783 | } | 844 | } |
| 784 | let chosen = body["resources"] | 845 | let chosen = chosen_resources(&body, &resources, shale_consent)?; |
| 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 | } | ||
| 796 | if list(&tx, "grant:")? | 846 | if list(&tx, "grant:")? |
| 797 | .iter() | 847 | .iter() |
| 798 | .filter(|g| g["user"] == owner_id) | 848 | .filter(|g| g["user"] == owner_id) |
| ... | @@ -826,13 +876,37 @@ pub async fn manage( | ... | @@ -826,13 +876,37 @@ pub async fn manage( |
| 826 | } | 876 | } |
| 827 | } | 877 | } |
| 828 | ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?, | 878 | ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?, |
| 829 | ["connections", id] if method == Method::DELETE => { | 879 | ["connections", id] |
| 830 | let grant = get(&tx, &format!("grant:{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 | if grant["user"] != owner_id { | 884 | if grant["user"] != owner_id { |
| 832 | return Err(Error::new(404, "No connection with that ID.")); | 885 | return Err(Error::new(404, "No connection with that ID.")); |
| 833 | } | 886 | } |
| 834 | revoke(&tx, id)?; | 887 | if method == Method::DELETE { |
| 835 | Value::Null | 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 | _ => return Err(Error::new(404, "No endpoint here.")), | 911 | _ => return Err(Error::new(404, "No endpoint here.")), |
| 838 | }; | 912 | }; |
| ... | @@ -849,6 +923,28 @@ pub async fn manage( | ... | @@ -849,6 +923,28 @@ pub async fn manage( |
| 849 | 923 | ||
| 850 | #[cfg(test)] | 924 | #[cfg(test)] |
| 851 | mod tests { | 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 | use super::*; | 948 | use super::*; |
| 853 | struct Fixture { | 949 | struct Fixture { |
| 854 | store: Arc<Store>, | 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,6 +618,9 @@ async fn rest(State(app): State<Arc<App>>, request: Request) -> Response { |
| 618 | let grant = app | 618 | let grant = app |
| 619 | .mcp | 619 | .mcp |
| 620 | .authenticate(request.headers(), &app.mcp.resource("agents"))?; | 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 | let id = string(&grant["id"]); | 624 | let id = string(&grant["id"]); |
| 622 | let method = request.method().clone(); | 625 | let method = request.method().clone(); |
| 623 | let path = request | 626 | let path = request |
| ... | @@ -778,13 +781,37 @@ impl ServerHandler for Agents { | ... | @@ -778,13 +781,37 @@ impl ServerHandler for Agents { |
| 778 | .into()) | 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 | pub fn router(app: Arc<App>) -> Router { | 797 | pub fn router(app: Arc<App>) -> Router { |
| 782 | let state = app.clone(); | 798 | let state = app.clone(); |
| 783 | let expected_host = | 799 | let expected_host = |
| 784 | app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); | 800 | app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); |
| 801 | let agent = env("STUDIO_AGENT_DIR", "agent"); | ||
| 785 | Router::new() | 802 | Router::new() |
| 786 | .route("/pairing", any(pairing)) | 803 | .route("/pairing", any(pairing)) |
| 787 | .route("/agent/connect", any(connect)) | 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 | .route("/api/v1/{*path}", any(rest)) | 815 | .route("/api/v1/{*path}", any(rest)) |
| 789 | .with_state(app.clone()) | 816 | .with_state(app.clone()) |
| 790 | .merge(mcp::router(app, "agents", move || { | 817 | .merge(mcp::router(app, "agents", move || { |
dashboard/src/shale.rs+41-11| ... | @@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String { | ... | @@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String { |
| 149 | fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> { | 149 | fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result<url::Url> { |
| 150 | if repository.is_empty() | 150 | if repository.is_empty() |
| 151 | || repository.len() > 255 | 151 | || repository.len() > 255 |
| 152 | || matches!(repository, "." | ".." | "-") | 152 | || repository |
| 153 | .split('/') | ||
| 154 | .any(|part| matches!(part, "" | "." | ".." | "-")) | ||
| 153 | || repository | 155 | || repository |
| 154 | .chars() | 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 | return Err(Error::new( | 159 | return Err(Error::new( |
| 158 | 400, | 160 | 400, |
| ... | @@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu | ... | @@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu |
| 164 | .path_segments_mut() | 166 | .path_segments_mut() |
| 165 | .unwrap() | 167 | .unwrap() |
| 166 | .clear() | 168 | .clear() |
| 167 | .push(repository) | 169 | .extend(repository.split('/')) |
| 168 | .extend(suffix.iter().copied()); | 170 | .extend(suffix.iter().copied()); |
| 169 | Ok(target) | 171 | Ok(target) |
| 170 | } | 172 | } |
| ... | @@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result<String> { | ... | @@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result<String> { |
| 184 | .map(str::to_owned) | 186 | .map(str::to_owned) |
| 185 | .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab.")) | 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 | let session = session(app, owner)?; | 190 | let session = session(app, owner)?; |
| 189 | username( | 191 | username( |
| 190 | &app.shale | 192 | &app.shale |
| 191 | .page(&app.shale.origin.join("/-/settings")?, &session) | 193 | .page(&app.shale.origin.join("/-/settings")?, &session) |
| 192 | .await?, | 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 | let body = app.shale.page(&app.shale.origin, &session).await?; | 200 | let body = app.shale.page(&app.shale.origin, &session).await?; |
| 195 | let document = document(&body, "page-index", None)?; | 201 | let document = document(&body, "page-index", None)?; |
| 196 | let mut repositories = Vec::new(); | 202 | let mut repositories = Vec::new(); |
| ... | @@ -359,12 +365,12 @@ impl ServerHandler for Shale { | ... | @@ -359,12 +365,12 @@ impl ServerHandler for Shale { |
| 359 | current(app, grant, &credential)?; | 365 | current(app, grant, &credential)?; |
| 360 | if name == "list_repositories" { | 366 | if name == "list_repositories" { |
| 361 | let mut repositories = repositories(app, string(&grant["user"])).await?; | 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 | current(app, grant, &credential)?; | 369 | current(app, grant, &credential)?; |
| 364 | return Ok(json!({"repositories":repositories})); | 370 | return Ok(json!({"repositories":repositories})); |
| 365 | } | 371 | } |
| 366 | let repository = arguments.get("repository").and_then(Value::as_str) | 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 | .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?; | 374 | .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?; |
| 369 | let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; | 375 | let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; |
| 370 | let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { | 376 | let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { |
| ... | @@ -556,6 +562,15 @@ pub async fn manage( | ... | @@ -556,6 +562,15 @@ pub async fn manage( |
| 556 | let owner_id = string(&owner["id"]); | 562 | let owner_id = string(&owner["id"]); |
| 557 | let key = format!("shale-session:{owner_id}"); | 563 | let key = format!("shale-session:{owner_id}"); |
| 558 | match *method { | 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 | Method::POST => { | 574 | Method::POST => { |
| 560 | let pending = if let Some(id) = body["request"].as_str() { | 575 | let pending = if let Some(id) = body["request"].as_str() { |
| 561 | let db = app.mcp.db.lock().unwrap(); | 576 | let db = app.mcp.db.lock().unwrap(); |
| ... | @@ -740,10 +755,10 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response { | ... | @@ -740,10 +755,10 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response { |
| 740 | let _ = app.shale.get("/-/logout", Some(&session)).await; | 755 | let _ = app.shale.get("/-/logout", Some(&session)).await; |
| 741 | return Err(error); | 756 | return Err(error); |
| 742 | } | 757 | } |
| 743 | let mut target = app.mcp.origin.join("mcp")?; | 758 | let target = app.mcp.origin.join(&match link["request"].as_str() { |
| 744 | if let Some(request) = link["request"].as_str() { | 759 | Some(request) => format!("connect/{request}"), |
| 745 | target.query_pairs_mut().append_pair("request", request); | 760 | None => "mcp/settings/shale".to_owned(), |
| 746 | } | 761 | })?; |
| 747 | Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response()) | 762 | Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response()) |
| 748 | }.await; | 763 | }.await; |
| 749 | let mut response = match result { | 764 | let mut response = match result { |
| ... | @@ -779,7 +794,11 @@ mod tests { | ... | @@ -779,7 +794,11 @@ mod tests { |
| 779 | "..", | 794 | "..", |
| 780 | "-", | 795 | "-", |
| 781 | "../other", | 796 | "../other", |
| 782 | "one/two", | 797 | "one//two", |
| 798 | "one/../two", | ||
| 799 | "one/./two", | ||
| 800 | "one/-/two", | ||
| 801 | "one/", | ||
| 783 | "one\\two", | 802 | "one\\two", |
| 784 | "%2e%2e", | 803 | "%2e%2e", |
| 785 | "one?x", | 804 | "one?x", |
| ... | @@ -795,6 +814,17 @@ mod tests { | ... | @@ -795,6 +814,17 @@ mod tests { |
| 795 | let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap(); | 814 | let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap(); |
| 796 | assert_eq!(path.origin(), origin.origin()); | 815 | assert_eq!(path.origin(), origin.origin()); |
| 797 | assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1"); | 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 | #[test] | 829 | #[test] |
| 800 | fn issue_pages_must_match_repository_identity_and_issue_number() { | 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,12 +823,20 @@ pub fn start(app: Arc<App>) { |
| 823 | .unwrap() | 823 | .unwrap() |
| 824 | .iter() | 824 | .iter() |
| 825 | .flat_map(|(id, job)| { | 825 | .flat_map(|(id, job)| { |
| 826 | array(&job["job"]["TaskGroups"]).iter() | 826 | array(&job["job"]["TaskGroups"]) |
| 827 | .iter() | ||
| 827 | .flat_map(|group| array(&group["Services"])) | 828 | .flat_map(|group| array(&group["Services"])) |
| 828 | .flat_map(move |service| { | 829 | .flat_map(move |service| { |
| 829 | array(&service["Tags"]).iter().filter_map(move |tag| { | 830 | array(&service["Tags"]).iter().filter_map(move |tag| { |
| 830 | string(tag).strip_prefix("studio-metrics-path=") | 831 | string(tag).strip_prefix("studio-metrics-path=").map( |
| 831 | .map(|path| (id.clone(), string(&service["Name"]).to_owned(), path.to_owned())) | 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,7 +850,10 @@ pub fn start(app: Arc<App>) { |
| 842 | let response = app | 850 | let response = app |
| 843 | .request( | 851 | .request( |
| 844 | Method::GET, | 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 | .timeout(Duration::from_secs(5)) | 858 | .timeout(Duration::from_secs(5)) |
| 848 | .send() | 859 | .send() |
| ... | @@ -852,7 +863,10 @@ pub fn start(app: Arc<App>) { | ... | @@ -852,7 +863,10 @@ pub fn start(app: Arc<App>) { |
| 852 | Method::POST, | 863 | Method::POST, |
| 853 | &format!( | 864 | &format!( |
| 854 | "{base}/api/v1/import/prometheus?{}", | 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 | .body(response.text().await?) | 872 | .body(response.text().await?) |
dashboard/src/users.rs+264-369| ... | @@ -1,85 +1,7 @@ | ... | @@ -1,85 +1,7 @@ |
| 1 | use crate::*; | 1 | use crate::*; |
| 2 | use axum::extract::{FromRequest, Multipart}; | 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 | fn uuid(id: &str) -> Result<()> { | 5 | fn uuid(id: &str) -> Result<()> { |
| 84 | if regex::Regex::new( | 6 | if regex::Regex::new( |
| 85 | 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}$", | 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,7 +35,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> { |
| 113 | let value = match key { | 35 | let value = match key { |
| 114 | "username" => { | 36 | "username" => { |
| 115 | let v = string(value).trim().to_lowercase(); | 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 | return Err(Error::new( | 39 | return Err(Error::new( |
| 118 | 400, | 40 | 400, |
| 119 | "Usernames use lowercase letters, digits, dots, dashes, and @", | 41 | "Usernames use lowercase letters, digits, dots, dashes, and @", |
| ... | @@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> { | ... | @@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> { |
| 126 | .as_str() | 48 | .as_str() |
| 127 | .ok_or_else(|| Error::new(400, "Enter a name or email address."))? | 49 | .ok_or_else(|| Error::new(400, "Enter a name or email address."))? |
| 128 | .trim(); | 50 | .trim(); |
| 51 | if v.len() > 254 { | ||
| 52 | return Err(Error::new(400, "Use a shorter name or email address.")); | ||
| 53 | } | ||
| 129 | if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) { | 54 | if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) { |
| 130 | return Err(Error::new(400, "Enter a full email address")); | 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,7 +63,7 @@ fn profile(body: &Value, full: bool) -> Result<Value> { |
| 138 | value.clone() | 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 | return Err(Error::new(400, "Invalid required actions.")); | 67 | return Err(Error::new(400, "Invalid required actions.")); |
| 143 | } | 68 | } |
| 144 | value.clone() | 69 | value.clone() |
| ... | @@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result<Value> { | ... | @@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result<Value> { |
| 151 | fn password(value: &Value) -> Result<&str> { | 76 | fn password(value: &Value) -> Result<&str> { |
| 152 | value | 77 | value |
| 153 | .as_str() | 78 | .as_str() |
| 154 | .filter(|s| s.chars().count() >= 8) | 79 | .filter(|s| s.chars().count() >= 8 && s.len() <= 1024) |
| 155 | .ok_or_else(|| Error::new(400, "Use at least 8 characters")) | 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 | pub async fn route( | 114 | pub async fn route( |
| 159 | app: Arc<App>, | 115 | app: Arc<App>, |
| 160 | method: &Method, | 116 | method: &Method, |
| ... | @@ -163,142 +119,207 @@ pub async fn route( | ... | @@ -163,142 +119,207 @@ pub async fn route( |
| 163 | body: Value, | 119 | body: Value, |
| 164 | ) -> Result<Response> { | 120 | ) -> Result<Response> { |
| 165 | if parts.is_empty() && method == Method::GET { | 121 | if parts.is_empty() && method == Method::GET { |
| 166 | return Ok(directory(app).await?.response()); | 122 | let db = app.auth.db.lock().unwrap(); |
| 167 | } | 123 | let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?; |
| 168 | if let Some(id) = parts.first() { | 124 | let ids = statement |
| 169 | uuid(id)?; | 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 { | 143 | if parts.is_empty() && method == Method::POST { |
| 172 | [] if method == Method::POST => { | 144 | let mut profile = profile(&body["profile"], true)?; |
| 173 | let mut profile = profile(&body["profile"], true)?; | 145 | let setup = string(&body["setup"]["kind"]); |
| 174 | let setup = string(&body["setup"]["kind"]); | 146 | if setup != "invite" && setup != "password" { |
| 175 | if setup == "email" && profile["email"].is_null() { | 147 | return Err(Error::new(400, "Choose an invitation or a password.")); |
| 176 | return Err(Error::new(400, "Add an email address to send a setup link")); | 148 | } |
| 177 | } | 149 | let hash = if setup == "password" { |
| 178 | if setup != "email" && setup != "password" { | 150 | Some( |
| 179 | return Err(Error::new(400, "Choose how this user signs in.")); | 151 | app.auth |
| 180 | } | 152 | .hash_password(password(&body["setup"]["password"])?) |
| 181 | if setup == "password" { | 153 | .await?, |
| 182 | password(&body["setup"]["password"])?; | 154 | ) |
| 183 | } | 155 | } else { |
| 184 | if !body["groups"].is_array() { | 156 | None |
| 185 | return Err(Error::new(400, "Choose groups.")); | 157 | }; |
| 186 | } | 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 | for group in array(&body["groups"]) { | 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 | if let Some(hash) = &hash { |
| 191 | json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"]) | 192 | auth::set_password(&transaction, &id, hash)?; |
| 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?; | ||
| 210 | } | 193 | } |
| 211 | if setup == "email" { | 194 | transaction.commit()?; |
| 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()); | ||
| 223 | } | 195 | } |
| 224 | [id] if method == Method::PATCH => { | 196 | return Ok((StatusCode::CREATED,axum::Json(json!({"id":id,"url":if setup=="invite" {Some(app.auth.setup_link(&id)?)}else{None}}))).into_response()); |
| 225 | let profile = profile(&body, false)?; | 197 | } |
| 226 | if profile["enabled"] == false { | 198 | let id = parts |
| 227 | spare(me, id, None).await?; | 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 | Value::Null | 233 | Value::Null |
| 231 | } | 234 | } |
| 232 | [id] if method == Method::DELETE => { | 235 | [_] if method == Method::DELETE => { |
| 233 | spare(me, id, None).await?; | 236 | if own { |
| 234 | call(&format!("/users/{id}"), Method::DELETE, None).await?; | 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 | Value::Null | 247 | Value::Null |
| 236 | } | 248 | } |
| 237 | [id, "groups", group] if method == Method::PUT || method == Method::DELETE => { | 249 | [_, "groups", group] if method == Method::PUT || method == Method::DELETE => { |
| 238 | uuid(group)?; | 250 | let name: Option<String> = transaction |
| 239 | if method == Method::DELETE { | 251 | .query_row("SELECT name FROM roles WHERE id=?", [group], |r| r.get(0)) |
| 240 | let groups = get("/roles").await?; | 252 | .optional()?; |
| 241 | let name = array(&groups) | 253 | let name = name |
| 242 | .iter() | 254 | .ok_or_else(|| Error::new(404, "This group no longer exists. Reload the page."))?; |
| 243 | .find(|g| g["id"] == *group) | 255 | if own && method == Method::DELETE && name == "infra-admin" { |
| 244 | .map(|g| string(&g["name"])); | 256 | return Err(Error::new( |
| 245 | spare(me, id, name).await?; | 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 | Value::Null | 272 | Value::Null |
| 258 | } | 273 | } |
| 259 | [id, "credentials"] if method == Method::GET => { | 274 | [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?, |
| 260 | get(&format!("/users/{id}/credentials")).await? | 275 | [_, "logout"] if method == Method::POST => { |
| 261 | } | 276 | transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; |
| 262 | [id, "logout"] if method == Method::POST => { | ||
| 263 | call(&format!("/users/{id}/logout"), Method::POST, None).await?; | ||
| 264 | Value::Null | 277 | Value::Null |
| 265 | } | 278 | } |
| 266 | [id, "actions-email"] if method == Method::POST => { | 279 | [_, "setup-link"] if method == Method::DELETE => { |
| 267 | let user = found(id).await?; | 280 | transaction.execute( |
| 268 | if user["email"].is_null() { | 281 | "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?", |
| 269 | return Err(Error::new(400, "Add an email address first")); | 282 | [id], |
| 270 | } | 283 | )?; |
| 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?; | ||
| 280 | Value::Null | 284 | Value::Null |
| 281 | } | 285 | } |
| 282 | [id, "password"] if method == Method::PUT => { | 286 | [_, "password"] if method == Method::PUT => { |
| 283 | let password = password(&body["password"])?; | ||
| 284 | if !body["temporary"].is_boolean() { | 287 | if !body["temporary"].is_boolean() { |
| 285 | return Err(Error::new( | 288 | return Err(Error::new( |
| 286 | 400, | 289 | 400, |
| 287 | "Choose whether this password is temporary.", | 290 | "Choose whether this password is temporary.", |
| 288 | )); | 291 | )); |
| 289 | } | 292 | } |
| 290 | call( | 293 | auth::set_password(&transaction, id, hash.as_deref().unwrap())?; |
| 291 | &format!("/users/{id}/reset-password"), | 294 | user["requiredActions"] = if body["temporary"] == true { |
| 292 | Method::PUT, | 295 | json!(["UPDATE_PASSWORD"]) |
| 293 | Some(json!({"type":"password","value":password,"temporary":body["temporary"]})), | 296 | } else { |
| 294 | ) | 297 | json!([]) |
| 295 | .await?; | 298 | }; |
| 299 | auth::save_user(&transaction, id, user)?; | ||
| 300 | transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; | ||
| 296 | Value::Null | 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 | if method != Method::GET { | 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 | Ok(if value.is_null() { | 324 | Ok(if value.is_null() { |
| 304 | StatusCode::NO_CONTENT.into_response() | 325 | StatusCode::NO_CONTENT.into_response() |
| ... | @@ -306,54 +327,6 @@ pub async fn route( | ... | @@ -306,54 +327,6 @@ pub async fn route( |
| 306 | Document::new(value).response() | 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 | fn image_type(bytes: &[u8]) -> Option<&'static str> { | 330 | fn image_type(bytes: &[u8]) -> Option<&'static str> { |
| 358 | if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { | 331 | if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { |
| 359 | Some("image/webp") | 332 | Some("image/webp") |
| ... | @@ -396,130 +369,62 @@ pub async fn account( | ... | @@ -396,130 +369,62 @@ pub async fn account( |
| 396 | me: &Value, | 369 | me: &Value, |
| 397 | ) -> Result<Response> { | 370 | ) -> Result<Response> { |
| 398 | let method = request.method().clone(); | 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 | let mut user = self_user(&app, me).await?; | 372 | let mut user = self_user(&app, me).await?; |
| 477 | let id = string(&user["id"]).to_owned(); | 373 | let id = string(&user["id"]).to_owned(); |
| 478 | let value = match parts { | 374 | let value = match parts { |
| 479 | [] if method == Method::GET => { | 375 | [] if method == Method::GET => { |
| 480 | let attributes = user | 376 | user["picture"] = user["attributes"]["picture"][0].clone(); |
| 481 | .as_object_mut() | 377 | user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?; |
| 482 | .unwrap() | 378 | user.as_object_mut().unwrap().remove("attributes"); |
| 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()?)); | ||
| 488 | user | 379 | user |
| 489 | } | 380 | } |
| 490 | [] if method == Method::PATCH => { | 381 | [] if method == Method::PATCH => { |
| 491 | let body: Value = serde_json::from_slice( | 382 | let body: Value = |
| 492 | &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?, | 383 | serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?; |
| 493 | ) | 384 | for key in ["firstName", "lastName", "email"] { |
| 494 | .map_err(|_| Error::new(400, "Invalid profile."))?; | 385 | if body.get(key).is_some() { |
| 495 | let mut value = serde_json::Map::new(); | 386 | let mut field = serde_json::Map::new(); |
| 496 | for key in ["firstName", "lastName"] { | 387 | field.insert(key.to_owned(), body[key].clone()); |
| 497 | if let Some(v) = body.get(key) { | 388 | let patch = profile(&Value::Object(field), false)?; |
| 498 | let v = v | 389 | user[key] = patch[key].clone(); |
| 499 | .as_str() | 390 | if key == "email" { |
| 500 | .ok_or_else(|| Error::new(400, "Enter a name."))? | 391 | user["emailVerified"] = json!(false); |
| 501 | .trim(); | 392 | } |
| 502 | value.insert( | ||
| 503 | key.into(), | ||
| 504 | if v.is_empty() { Value::Null } else { json!(v) }, | ||
| 505 | ); | ||
| 506 | } | 393 | } |
| 507 | } | 394 | } |
| 508 | call( | 395 | user["requiredActions"] = json!( |
| 509 | &format!("/users/{id}"), | 396 | array(&user["requiredActions"]) |
| 510 | Method::PUT, | 397 | .iter() |
| 511 | Some(Value::Object(value)), | 398 | .filter(|v| **v != "UPDATE_PROFILE") |
| 512 | ) | 399 | .collect::<Vec<_>>() |
| 513 | .await?; | 400 | ); |
| 401 | auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; | ||
| 514 | Value::Null | 402 | Value::Null |
| 515 | } | 403 | } |
| 516 | ["verify-email"] if method == Method::POST => { | 404 | ["credentials", credential] if method == Method::DELETE => { |
| 517 | call( | 405 | app.auth.recent(request.headers())?; |
| 518 | &format!("/users/{id}/execute-actions-email"), | 406 | let mut db = app.auth.db.lock().unwrap(); |
| 519 | Method::PUT, | 407 | let transaction = db.transaction()?; |
| 520 | Some(json!(["VERIFY_EMAIL"])), | 408 | let count: i64 = transaction.query_row( |
| 521 | ) | 409 | "SELECT count(*) FROM credentials WHERE user_id=?", |
| 522 | .await?; | 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 | Value::Null | 428 | Value::Null |
| 524 | } | 429 | } |
| 525 | ["picture"] if method == Method::PUT => { | 430 | ["picture"] if method == Method::PUT => { |
| ... | @@ -554,32 +459,22 @@ pub async fn account( | ... | @@ -554,32 +459,22 @@ pub async fn account( |
| 554 | tokio::fs::create_dir_all(app.data.join("pictures")).await?; | 459 | tokio::fs::create_dir_all(app.data.join("pictures")).await?; |
| 555 | tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?; | 460 | tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?; |
| 556 | let picture = format!( | 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 | (now() * 1000.0) as u64 | 464 | (now() * 1000.0) as u64 |
| 559 | ); | 465 | ); |
| 560 | call( | 466 | user["attributes"]["picture"] = json!([picture]); |
| 561 | &format!("/users/{id}"), | 467 | auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; |
| 562 | Method::PUT, | ||
| 563 | Some(json!({"attributes":{"picture":[picture]}})), | ||
| 564 | ) | ||
| 565 | .await?; | ||
| 566 | json!({"picture":picture}) | 468 | json!({"picture":picture}) |
| 567 | } | 469 | } |
| 568 | ["picture"] if method == Method::DELETE => { | 470 | ["picture"] if method == Method::DELETE => { |
| 569 | call( | 471 | user["attributes"]["picture"] = Value::Null; |
| 570 | &format!("/users/{id}"), | 472 | auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; |
| 571 | Method::PUT, | ||
| 572 | Some(json!({"attributes":{"picture":null}})), | ||
| 573 | ) | ||
| 574 | .await?; | ||
| 575 | let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await; | 473 | let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await; |
| 576 | Value::Null | 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 | Ok(if value.is_null() { | 478 | Ok(if value.is_null() { |
| 584 | StatusCode::NO_CONTENT.into_response() | 479 | StatusCode::NO_CONTENT.into_response() |
| 585 | } else { | 480 | } else { |
dashboard/web/api.contract.ts+16-17| ... | @@ -1,4 +1,4 @@ | ... | @@ -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 | import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts"; | 2 | import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts"; |
| 3 | import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts"; | 3 | import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts"; |
| 4 | import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts"; | 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,10 +55,10 @@ type ExplorerRoutes<Prefix extends string> = { [P in keyof Explorer as `${Prefix |
| 55 | 55 | ||
| 56 | export type Api = Hono<{}, { | 56 | export type Api = Hono<{}, { |
| 57 | "/mcp": { $get: Endpoint<Connections>; }; | 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 | "/mcp/consent/:id": { | 59 | "/mcp/consent/:id": { |
| 60 | $get: Endpoint<Consent, { param: { id: string } }>; | 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 | "/mcp/relay/pair": { $post: Endpoint<Connections["machines"][number], { json: { code: string } }>; }; | 63 | "/mcp/relay/pair": { $post: Endpoint<Connections["machines"][number], { json: { code: string } }>; }; |
| 64 | "/mcp/relay/machines/:id": { | 64 | "/mcp/relay/machines/:id": { |
| ... | @@ -66,7 +66,11 @@ export type Api = Hono<{}, { | ... | @@ -66,7 +66,11 @@ export type Api = Hono<{}, { |
| 66 | $delete: Endpoint<null, { param: { id: string } }, 204>; | 66 | $delete: Endpoint<null, { param: { id: string } }, 204>; |
| 67 | }; | 67 | }; |
| 68 | "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; }; | 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 | "/me": { | 75 | "/me": { |
| 72 | $get: Endpoint<Me>; | 76 | $get: Endpoint<Me>; |
| ... | @@ -210,7 +214,7 @@ export type Api = Hono<{}, { | ... | @@ -210,7 +214,7 @@ export type Api = Hono<{}, { |
| 210 | }; | 214 | }; |
| 211 | "/users": { | 215 | "/users": { |
| 212 | $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>; | 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 | "/users/:id": { | 219 | "/users/:id": { |
| 216 | $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">; | 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,8 +230,9 @@ export type Api = Hono<{}, { |
| 226 | "/users/:id/logout": { | 230 | "/users/:id/logout": { |
| 227 | $post: Endpoint<null, { param: { id: string; }; }, 204, "body">; | 231 | $post: Endpoint<null, { param: { id: string; }; }, 204, "body">; |
| 228 | }; | 232 | }; |
| 229 | "/users/:id/actions-email": { | 233 | "/users/:id/setup-link": { |
| 230 | $post: Endpoint<null, { param: { id: string; }; }, 204, "body">; | 234 | $post: Endpoint<{url:string}, { param: { id: string; }; }>; |
| 235 | $delete: Endpoint<null, { param: { id: string; }; }, 204, "body">; | ||
| 231 | }; | 236 | }; |
| 232 | "/users/:id/password": { | 237 | "/users/:id/password": { |
| 233 | $put: Endpoint<null, { param: { id: string; }; } & { json: { password: string; temporary: boolean; }; }, 204, "body">; | 238 | $put: Endpoint<null, { param: { id: string; }; } & { json: { password: string; temporary: boolean; }; }, 204, "body">; |
| ... | @@ -283,11 +288,8 @@ export type Api = Hono<{}, { | ... | @@ -283,11 +288,8 @@ export type Api = Hono<{}, { |
| 283 | $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">; | 288 | $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">; |
| 284 | }; | 289 | }; |
| 285 | "/account": { | 290 | "/account": { |
| 286 | $get: Endpoint<User & {picture: string | null; credentials:Credential[]; console: string | null}>; | 291 | $get: Endpoint<User & {picture: string | null; credentials:Credential[]}>; |
| 287 | $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; }; }, 204, "body">; | 292 | $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; email?: string | undefined; }; }, 204, "body">; |
| 288 | }; | ||
| 289 | "/account/verify-email": { | ||
| 290 | $post: Endpoint<null, {}, 204, "body">; | ||
| 291 | }; | 293 | }; |
| 292 | "/account/picture": { | 294 | "/account/picture": { |
| 293 | $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; | 295 | $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; |
| ... | @@ -296,10 +298,7 @@ export type Api = Hono<{}, { | ... | @@ -296,10 +298,7 @@ export type Api = Hono<{}, { |
| 296 | "/account/pictures/:id": { | 298 | "/account/pictures/:id": { |
| 297 | $get: Endpoint<Uint8Array, { param: { id: string; }; }, 200, "body">; | 299 | $get: Endpoint<Uint8Array, { param: { id: string; }; }, 200, "body">; |
| 298 | }; | 300 | }; |
| 299 | "/account/actions/:action": { | 301 | "/account/credentials/:id": { |
| 300 | $get: Endpoint<undefined, { param: { action: string; }; }, 302, "redirect">; | 302 | $delete: Endpoint<null, { param: { id: string; }; }, 204, "body">; |
| 301 | }; | ||
| 302 | "/account/sign-out": { | ||
| 303 | $get: Endpoint<undefined, {}, 302, "redirect">; | ||
| 304 | }; | 303 | }; |
| 305 | } & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>; | 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,6 +19,7 @@ import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts"; |
| 19 | import { queries } from "../api.ts"; | 19 | import { queries } from "../api.ts"; |
| 20 | import snowflake from "../snowflake.svg"; | 20 | import snowflake from "../snowflake.svg"; |
| 21 | import { bytes, cores, plural } from "../format.ts"; | 21 | import { bytes, cores, plural } from "../format.ts"; |
| 22 | import { signOut } from "../auth.ts"; | ||
| 22 | import { account, Avatar, displayName } from "../pages/Account.tsx"; | 23 | import { account, Avatar, displayName } from "../pages/Account.tsx"; |
| 23 | import { status } from "../pages/Overview.tsx"; | 24 | import { status } from "../pages/Overview.tsx"; |
| 24 | import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; | 25 | import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; |
| ... | @@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) { | ... | @@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) { |
| 243 | </button> | 244 | </button> |
| 244 | <div ref={menu} id="account-menu" popover class="account-menu"> | 245 | <div ref={menu} id="account-menu" popover class="account-menu"> |
| 245 | <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A> | 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 | <Show when={props.me.viewing || props.me.sections.includes("admin")}> | 248 | <Show when={props.me.viewing || props.me.sections.includes("admin")}> |
| 248 | <form class="view-as" onSubmit={(event) => { | 249 | <form class="view-as" onSubmit={(event) => { |
| 249 | event.preventDefault(); | 250 | event.preventDefault(); |
dashboard/web/main.tsx+8| ... | @@ -20,6 +20,8 @@ import { Deploys } from "./pages/Deploys.tsx"; | ... | @@ -20,6 +20,8 @@ import { Deploys } from "./pages/Deploys.tsx"; |
| 20 | import { Deploy } from "./pages/Deploy.tsx"; | 20 | import { Deploy } from "./pages/Deploy.tsx"; |
| 21 | import { VMs } from "./pages/VMs.tsx"; | 21 | import { VMs } from "./pages/VMs.tsx"; |
| 22 | import { MCP } from "./pages/MCP.tsx"; | 22 | import { MCP } from "./pages/MCP.tsx"; |
| 23 | import { MCPConsent } from "./pages/MCPConsent.tsx"; | ||
| 24 | import { SignIn } from "./pages/SignIn.tsx"; | ||
| 23 | import { Account } from "./pages/Account.tsx"; | 25 | import { Account } from "./pages/Account.tsx"; |
| 24 | import "./styles.css"; | 26 | import "./styles.css"; |
| 25 | 27 | ||
| ... | @@ -30,10 +32,12 @@ const preload = (...list: { preload(): void }[]) => () => list.forEach((query) = | ... | @@ -30,10 +32,12 @@ const preload = (...list: { preload(): void }[]) => () => list.forEach((query) = |
| 30 | 32 | ||
| 31 | function Shell(props: RouteSectionProps) { | 33 | function Shell(props: RouteSectionProps) { |
| 32 | const location = useLocation(); | 34 | const location = useLocation(); |
| 35 | const consent = () => location.pathname.startsWith("/connect/") || location.pathname === "/mcp" && new URLSearchParams(location.search).has("request"); | ||
| 33 | const section = () => location.pathname.startsWith("/services/") ? "admin" | 36 | const section = () => location.pathname.startsWith("/services/") ? "admin" |
| 34 | : PAGES.find((page) => page.href !== "/" && location.pathname.startsWith(page.href))?.section; | 37 | : PAGES.find((page) => page.href !== "/" && location.pathname.startsWith(page.href))?.section; |
| 35 | return ( | 38 | return ( |
| 36 | <> | 39 | <> |
| 40 | <Show when={location.pathname !== "/sign-in" && !consent()} fallback={<Show when={consent()} fallback={props.children}><MCPConsent /></Show>}> | ||
| 37 | <Loaded data={me} what="your account" retry={refetch} skeleton={<div class="shell"><div class="sidebar" /><main class="main" /></div>}> | 41 | <Loaded data={me} what="your account" retry={refetch} skeleton={<div class="shell"><div class="sidebar" /><main class="main" /></div>}> |
| 38 | {(user) => ( | 42 | {(user) => ( |
| 39 | <div class="shell"> | 43 | <div class="shell"> |
| ... | @@ -55,6 +59,7 @@ function Shell(props: RouteSectionProps) { | ... | @@ -55,6 +59,7 @@ function Shell(props: RouteSectionProps) { |
| 55 | </div> | 59 | </div> |
| 56 | )} | 60 | )} |
| 57 | </Loaded> | 61 | </Loaded> |
| 62 | </Show> | ||
| 58 | <Tooltips /> | 63 | <Tooltips /> |
| 59 | <Toasts /> | 64 | <Toasts /> |
| 60 | </> | 65 | </> |
| ... | @@ -63,6 +68,7 @@ function Shell(props: RouteSectionProps) { | ... | @@ -63,6 +68,7 @@ function Shell(props: RouteSectionProps) { |
| 63 | 68 | ||
| 64 | render(() => ( | 69 | render(() => ( |
| 65 | <Router root={Shell}> | 70 | <Router root={Shell}> |
| 71 | <Route path="/sign-in" component={SignIn} /> | ||
| 66 | <Route path="/" component={() => <Overview me={me.latest!} />} preload={() => { | 72 | <Route path="/" component={() => <Overview me={me.latest!} />} preload={() => { |
| 67 | queries.launcher.preload(); | 73 | queries.launcher.preload(); |
| 68 | if (me.latest?.sections.includes("metrics")) preload(queries.host, queries.storage, queries.services)(); | 74 | if (me.latest?.sections.includes("metrics")) preload(queries.host, queries.storage, queries.services)(); |
| ... | @@ -81,6 +87,8 @@ render(() => ( | ... | @@ -81,6 +87,8 @@ render(() => ( |
| 81 | <Route path="/deploys/:id/:tab?" component={Deploy} preload={preload(queries.deploys, queries.services)} /> | 87 | <Route path="/deploys/:id/:tab?" component={Deploy} preload={preload(queries.deploys, queries.services)} /> |
| 82 | <Route path="/vms" component={VMs} preload={preload(queries.vms)} /> | 88 | <Route path="/vms" component={VMs} preload={preload(queries.vms)} /> |
| 83 | <Route path="/mcp" component={MCP} /> | 89 | <Route path="/mcp" component={MCP} /> |
| 90 | <Route path="/mcp/settings/:catalog" component={MCP} /> | ||
| 91 | <Route path="/connect/:id" component={MCPConsent} /> | ||
| 84 | <Route path="/account" component={Account} preload={preload(queries.launcher)} /> | 92 | <Route path="/account" component={Account} preload={preload(queries.launcher)} /> |
| 85 | <Route path="*" component={() => <div class="empty">No page here</div>} /> | 93 | <Route path="*" component={() => <div class="empty">No page here</div>} /> |
| 86 | </Router> | 94 | </Router> |
dashboard/web/pages/Account.tsx+65-69| ... | @@ -1,17 +1,18 @@ | ... | @@ -1,17 +1,18 @@ |
| 1 | import { useNavigate, useSearchParams } from "@solidjs/router"; | 1 | import { useSearchParams } from "@solidjs/router"; |
| 2 | import { createResource, createSignal, For, onMount, Show } from "solid-js"; | 2 | import { createResource, createSignal, For, Show } from "solid-js"; |
| 3 | import { parseResponse } from "hono/client"; | 3 | import { parseResponse } from "hono/client"; |
| 4 | import type { User } from "../types/users.ts"; | 4 | import type { User } from "../types/users.ts"; |
| 5 | import { api, queries, reason } from "../api.ts"; | 5 | import { api, reason } from "../api.ts"; |
| 6 | import { Ago } from "../components/Ago.tsx"; | 6 | import { Ago } from "../components/Ago.tsx"; |
| 7 | import { lastGood, Loaded } from "../components/Loaded.tsx"; | 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 | import { Reveal } from "../components/Reveal.tsx"; | 10 | import { Reveal } from "../components/Reveal.tsx"; |
| 10 | import { toast } from "../components/Toast.tsx"; | 11 | import { toast } from "../components/Toast.tsx"; |
| 11 | import "./Account.css"; | 12 | import "./Account.css"; |
| 12 | 13 | ||
| 13 | /** The signed-in user's Keycloak record, shared with the sidebar corner. */ | 14 | /** Shared with the sidebar corner. */ |
| 14 | export const [account, { refetch: refetchAccount }] = createResource(() => parseResponse(api.account.$get())); | 15 | export const [account, { refetch: refetchAccount }] = createResource(() => window.location.pathname !== "/sign-in", () => parseResponse(api.account.$get())); |
| 15 | 16 | ||
| 16 | export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) => | 17 | export const displayName = (user: Pick<User, "username" | "firstName" | "lastName">) => |
| 17 | [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username; | 18 | [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username; |
| ... | @@ -25,14 +26,7 @@ export function Avatar(props: { picture: string | null; name: string }) { | ... | @@ -25,14 +26,7 @@ export function Avatar(props: { picture: string | null; name: string }) { |
| 25 | } | 26 | } |
| 26 | 27 | ||
| 27 | const PICTURE_SIZE = 256; | 28 | const PICTURE_SIZE = 256; |
| 28 | const FIELDS = ["firstName", "lastName"] as const; | 29 | const FIELDS = ["firstName", "lastName", "email"] 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 | }; | ||
| 36 | 30 | ||
| 37 | /** 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. */ | 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 | async function square(file: File) { | 32 | async function square(file: File) { |
| ... | @@ -48,24 +42,12 @@ async function square(file: File) { | ... | @@ -48,24 +42,12 @@ async function square(file: File) { |
| 48 | } | 42 | } |
| 49 | 43 | ||
| 50 | export function Account() { | 44 | export function Account() { |
| 51 | const [params] = useSearchParams<{ kc_action?: string; kc_action_status?: string }>(); | 45 | const [params] = useSearchParams<{ welcome?: string }>(); |
| 52 | const navigate = useNavigate(); | 46 | const [adding, setAdding] = createSignal(false); |
| 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 | |||
| 62 | return ( | 47 | return ( |
| 63 | <div class="page account-page"> | 48 | <div class="page account-page"> |
| 64 | <div class="page-head"> | 49 | <div class="page-head"> |
| 65 | <h1>profile</h1> | 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 | </div> | 51 | </div> |
| 70 | <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={ | 52 | <Loaded data={account} what="your profile" retry={refetchAccount} skeleton={ |
| 71 | <div class="account-grid"> | 53 | <div class="account-grid"> |
| ... | @@ -75,6 +57,20 @@ export function Account() { | ... | @@ -75,6 +57,20 @@ export function Account() { |
| 75 | }> | 57 | }> |
| 76 | {(user) => ( | 58 | {(user) => ( |
| 77 | <div class="account-grid"> | 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 | <Profile user={user()} /> | 74 | <Profile user={user()} /> |
| 79 | <SignIn user={user()} /> | 75 | <SignIn user={user()} /> |
| 80 | </div> | 76 | </div> |
| ... | @@ -88,7 +84,7 @@ type Self = NonNullable<typeof account.latest>; | ... | @@ -88,7 +84,7 @@ type Self = NonNullable<typeof account.latest>; |
| 88 | 84 | ||
| 89 | function Profile(props: { user: Self }) { | 85 | function Profile(props: { user: Self }) { |
| 90 | const [dirty, setDirty] = createSignal(false); | 86 | const [dirty, setDirty] = createSignal(false); |
| 91 | const [pending, setPending] = createSignal<"save" | "picture" | "verify">(); | 87 | const [pending, setPending] = createSignal<"save" | "picture">(); |
| 92 | const [error, setError] = createSignal(""); | 88 | const [error, setError] = createSignal(""); |
| 93 | const [pictureError, setPictureError] = createSignal(""); | 89 | const [pictureError, setPictureError] = createSignal(""); |
| 94 | const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>; | 90 | const inputs = {} as Record<(typeof FIELDS)[number], HTMLInputElement>; |
| ... | @@ -103,7 +99,7 @@ function Profile(props: { user: Self }) { | ... | @@ -103,7 +99,7 @@ function Profile(props: { user: Self }) { |
| 103 | setError(""); | 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 | setPending(key); | 103 | setPending(key); |
| 108 | fail(""); | 104 | fail(""); |
| 109 | try { | 105 | try { |
| ... | @@ -141,11 +137,6 @@ function Profile(props: { user: Self }) { | ... | @@ -141,11 +137,6 @@ function Profile(props: { user: Self }) { |
| 141 | await refetchAccount(); | 137 | await refetchAccount(); |
| 142 | }, setPictureError); | 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 | return ( | 140 | return ( |
| 150 | <section class="card"> | 141 | <section class="card"> |
| 151 | <div class="picture-row"> | 142 | <div class="picture-row"> |
| ... | @@ -175,16 +166,8 @@ function Profile(props: { user: Self }) { | ... | @@ -175,16 +166,8 @@ function Profile(props: { user: Self }) { |
| 175 | <label class="label" for="last-name">last name</label> | 166 | <label class="label" for="last-name">last name</label> |
| 176 | <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name" | 167 | <input id="last-name" ref={inputs.lastName} class="search" value={props.user.lastName ?? ""} autocomplete="family-name" |
| 177 | readOnly={pending() === "save"} /> | 168 | readOnly={pending() === "save"} /> |
| 178 | <span class="label">email</span> | 169 | <label class="label" for="profile-email">email</label> |
| 179 | <span class="email"> | 170 | <input id="profile-email" ref={inputs.email} class="search" type="email" value={props.user.email ?? ""} autocomplete="email" readOnly={pending() === "save"} /> |
| 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> | ||
| 188 | <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show> | 171 | <Show when={error()}><span /><p class="error" role="alert">{error()}</p></Show> |
| 189 | <span /> | 172 | <span /> |
| 190 | <Reveal when={dirty()}> | 173 | <Reveal when={dirty()}> |
| ... | @@ -206,28 +189,41 @@ function Profile(props: { user: Self }) { | ... | @@ -206,28 +189,41 @@ function Profile(props: { user: Self }) { |
| 206 | function SignIn(props: { user: Self }) { | 189 | function SignIn(props: { user: Self }) { |
| 207 | const password = () => props.user.credentials.find((credential) => credential.type === "password"); | 190 | const password = () => props.user.credentials.find((credential) => credential.type === "password"); |
| 208 | const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn")); | 191 | const passkeys = () => props.user.credentials.filter((credential) => credential.type.startsWith("webauthn")); |
| 209 | return ( | 192 | const [busy, setBusy] = createSignal(false); |
| 210 | <section class="card"> | 193 | const [error, setError] = createSignal(""); |
| 211 | <h2 class="card-title">sign-in</h2> | 194 | const run = async (work: () => Promise<unknown>) => { |
| 212 | <div class="credentials"> | 195 | setBusy(true); setError(""); |
| 213 | <span class="label">password</span> | 196 | try { await work(); await refetchAccount(); } catch(failure) { setError(authReason(failure)); } finally { setBusy(false); } |
| 214 | <span> | 197 | }; |
| 215 | <Show when={password()} fallback={<span class="muted">none</span>}>{(set) => <>set <Ago t={set().createdDate / 1000} /></>}</Show> | 198 | const change = () => showConfirmDialog({ |
| 216 | </span> | 199 | title: password() ? "Change password" : "Set password", confirmLabel: "save password", |
| 217 | <a class="button small" href="/api/account/actions/UPDATE_PASSWORD">{password() ? "change" : "set password"}</a> | 200 | body: <> |
| 218 | <span class="label">passkeys</span> | 201 | <Show when={password()}><label class="field">current password<input name="current" class="search" type="password" required autocomplete="current-password" /></label></Show> |
| 219 | <span><Show when={!passkeys().length}><span class="muted">none</span></Show></span> | 202 | <label class="field">new password<input name="password" class="search" type="password" required minLength={8} maxLength={1024} autocomplete="new-password" /></label> |
| 220 | <a class="button small" href="/api/account/actions/webauthn-register-passwordless">add passkey</a> | 203 | </>, |
| 221 | <For each={passkeys()}> | 204 | onConfirm: async (form) => { |
| 222 | {(passkey) => ( | 205 | try { |
| 223 | <> | 206 | const {csrf} = await authRequest<{csrf:string}>("status"); |
| 224 | <span /> | 207 | await authRequest("password/change", {csrf, current: String(form.get("current") ?? ""), password: String(form.get("password") ?? "")}); |
| 225 | <span class="passkey">{passkey.userLabel ?? "unnamed"} <span class="muted"><Ago t={passkey.createdDate / 1000} /></span></span> | 208 | await refetchAccount(); toast("Saved your password"); |
| 226 | <a class="button small" href={`/api/account/actions/delete_credential:${passkey.id}`}>remove</a> | 209 | } catch(failure) { setError(authReason(failure)); throw failure; } |
| 227 | </> | 210 | }, |
| 228 | )} | 211 | }); |
| 229 | </For> | 212 | return <section class="card"> |
| 230 | </div> | 213 | <h2 class="card-title">sign-in</h2> |
| 231 | </section> | 214 | <p class="muted">These sign-in methods work with Snowglobe and Files.</p> |
| 232 | ); | 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,12 +1,69 @@ |
| 1 | .mcp-page h2 { margin-top: 2rem; } | 1 | .mcp-page { max-width: 1100px; } |
| 2 | .mcp-connector, .mcp-consent { padding: 1.5rem; border: 1px solid var(--line); border-radius: 8px; margin: 1rem 0; } | 2 | .mcp-page h2 { font-size: 17px; margin: 0 0 12px; color: var(--text); } |
| 3 | .mcp-connector h3, .mcp-consent h2 { margin-top: 0; } | 3 | .mcp-page h3 { font-size: 15px; margin: 0; } |
| 4 | .mcp-resources { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .75rem; margin: 1.5rem 0; } | 4 | .mcp-page p { color: var(--text-2); } |
| 5 | .mcp-actions { display: flex; gap: .75rem; } | 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 | .mcp-page table { width: 100%; text-align: left; border-collapse: collapse; } | 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); } | 35 | .mcp-page th, .mcp-page td { padding: 12px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; } |
| 8 | 36 | .mcp-page form { margin: 16px 0; } | |
| 9 | .mcp-page form { margin: 1rem 0; } | 37 | .mcp-page form > label { display: flex; align-items: center; gap: 12px; } |
| 10 | .mcp-page form label { display: flex; align-items: center; gap: .75rem; } | 38 | .mcp-page input { padding: 8px; } |
| 11 | .mcp-page input { padding: .5rem; } | 39 | .mcp-page form > button { margin-top: 12px; } |
| 12 | .mcp-page form > button { margin-top: .75rem; } | 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,94 +1,106 @@ |
| 1 | import { useLocation } from "@solidjs/router"; | 1 | import { A, useParams } from "@solidjs/router"; |
| 2 | import { parseResponse } from "hono/client"; | 2 | import { parseResponse } from "hono/client"; |
| 3 | import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; | 3 | import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js"; |
| 4 | import { api, reason } from "../api.ts"; | 4 | import { api, reason } from "../api.ts"; |
| 5 | import { Ago } from "../components/Ago.tsx"; | 5 | import { Ago } from "../components/Ago.tsx"; |
| 6 | import { Checkbox } from "../components/Checkbox.tsx"; | 6 | import { Checkbox } from "../components/Checkbox.tsx"; |
| 7 | import { Copy } from "../components/Copy.tsx"; | 7 | import { Copy } from "../components/Copy.tsx"; |
| 8 | import { showConfirmDialog } from "../components/Dialog.tsx"; | ||
| 8 | import { Loaded } from "../components/Loaded.tsx"; | 9 | import { Loaded } from "../components/Loaded.tsx"; |
| 9 | import { toast } from "../components/Toast.tsx"; | 10 | import { toast } from "../components/Toast.tsx"; |
| 11 | import { MCPAccess } from "./MCPAccess.tsx"; | ||
| 12 | import type { Access, Catalog } from "../types/mcp.ts"; | ||
| 10 | import "./MCP.css"; | 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 | export function MCP() { | 21 | export function MCP() { |
| 13 | const location = useLocation(); | 22 | const params = useParams<{ catalog?: string }>(); |
| 14 | const request = () => new URLSearchParams(location.search).get("request"); | ||
| 15 | const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get())); | 23 | const [overview, { refetch, mutate }] = createResource(() => parseResponse(api.mcp.$get())); |
| 16 | const [consent, { refetch: retryConsent }] = createResource(() => request() || false, | 24 | const [shale, { refetch: retryShale }] = createResource(() => params.catalog === "shale", |
| 17 | (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } }))); | 25 | () => parseResponse(api.mcp.shale.$get())); |
| 18 | const [picked, setPicked] = createSignal<string[]>([]); | 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 | const [busy, setBusy] = createSignal(false); | 34 | const [busy, setBusy] = createSignal(false); |
| 20 | const [code, setCode] = createSignal(""); | 35 | const [code, setCode] = createSignal(""); |
| 21 | const [keyName, setKeyName] = createSignal(""); | 36 | const [keyName, setKeyName] = createSignal(""); |
| 22 | const [keyMachines, setKeyMachines] = createSignal<string[]>([]); | 37 | const [keyMachines, setKeyMachines] = createSignal<string[]>([]); |
| 23 | const [control, setControl] = createSignal(false); | 38 | const [control, setControl] = createSignal(false); |
| 24 | const [key, setKey] = createSignal(""); | 39 | const [key, setKey] = createSignal(""); |
| 25 | createEffect(() => { request(); setPicked([]); setBusy(false); }); | 40 | createEffect(() => { params.catalog; setEditing(""); setKey(""); }); |
| 26 | let events: EventSource | undefined; | ||
| 27 | createEffect(() => { | 41 | createEffect(() => { |
| 28 | if (!overview() || events) return; | 42 | if (params.catalog !== "agents") return; |
| 29 | events = new EventSource("/api/mcp/relay/live"); | 43 | const events = new EventSource("/api/mcp/relay/live"); |
| 30 | events.onmessage = (event) => { | 44 | events.onmessage = (event) => { |
| 31 | const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data); | 45 | const machines: NonNullable<ReturnType<typeof overview>>["machines"] = JSON.parse(event.data); |
| 32 | mutate((previous) => previous && { ...previous, machines }); | 46 | mutate((previous) => previous && { ...previous, machines }); |
| 33 | }; | 47 | }; |
| 48 | onCleanup(() => events.close()); | ||
| 34 | }); | 49 | }); |
| 35 | onCleanup(() => events?.close()); | ||
| 36 | const linkShale = async () => { | 50 | const linkShale = async () => { |
| 37 | setBusy(true); | 51 | setBusy(true); |
| 38 | try { | 52 | try { const result = await parseResponse(api.mcp.shale.$post({ json: {} })); window.location.assign(result.redirect); } |
| 39 | const result = await parseResponse(api.mcp.shale.$post({ json: { request: consent()?.scopes.includes("shale:read") ? request() || undefined : undefined } })); | 53 | catch (error) { toast(reason(error)); setBusy(false); } |
| 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); } | ||
| 49 | }; | 54 | }; |
| 50 | return <div class="page mcp-page"> | 55 | return <div class="page mcp-page"> |
| 51 | <header class="page-header"><h1>MCP</h1></header> | 56 | <Loaded data={overview} what="MCP settings" retry={refetch}> |
| 52 | <Show when={request()}> | 57 | {(data) => { |
| 53 | <Loaded data={consent} what="connection request" retry={retryConsent}> | 58 | const catalog = () => data().catalogs.find((catalog) => catalog.id === params.catalog); |
| 54 | {(details) => <section class="mcp-consent"> | 59 | const connections = () => data().connections.filter((connection) => connection.catalog === catalog()?.id); |
| 55 | <h2>Connect {details().client}</h2> | 60 | return <> |
| 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> | 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> |
| 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> | 62 | <nav class="mcp-navigation" aria-label="MCP settings"> |
| 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> | 63 | <A href="/mcp" end>Overview</A> |
| 59 | <div class="mcp-resources"><For each={details().resources}>{(resource) => | 64 | <For each={data().catalogs}>{(catalog) => <A href={`/mcp/settings/${catalog.id}`}>{catalog.name}</A>}</For> |
| 60 | <Checkbox checked={picked().includes(resource.id)} onChange={(checked) => setPicked(checked ? [...picked(), resource.id] : picked().filter((item) => item !== resource.id))}>{resource.name}</Checkbox> | 65 | </nav> |
| 61 | }</For></div> | 66 | <Show when={!params.catalog}> |
| 62 | <Show when={!details().linked}><p>Link your Shale account to choose repositories.</p></Show> | 67 | <div class="mcp-catalogs"><For each={data().catalogs}>{(catalog) => { |
| 63 | <Show when={details().linked && !details().resources.length}><p>No resources are available to your account.</p></Show> | 68 | const count = () => data().connections.filter((connection) => connection.catalog === catalog.id).length; |
| 64 | <Show when={details().scopes.includes("offline_access")}><p class="muted">This connection can refresh access without another sign-in.</p></Show> | 69 | return <A href={`/mcp/settings/${catalog.id}`} class="mcp-catalog-card"> |
| 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> | 70 | <div class="mcp-card-heading"><h2>{catalog.name}</h2><span aria-hidden="true">↗</span></div> |
| 66 | <button class="button secondary" disabled={busy()} onClick={() => answer(true)}>Decline</button></div> | 71 | <p>{descriptions[catalog.id]}</p> |
| 67 | </section>} | 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> |
| 68 | </Loaded> | 73 | <span>{count()} {count() === 1 ? "connection" : "connections"}</span></div> |
| 69 | </Show> | 74 | </A>; }}</For></div> |
| 70 | <Loaded data={overview} what="MCP connections" retry={refetch}> | 75 | <p class="muted">Open a connector to copy its installation URL or change a client’s access.</p> |
| 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>} | ||
| 80 | </Show> | 76 | </Show> |
| 81 | <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button> | 77 | <Show when={params.catalog && !catalog()}><p class="empty">No connector here.</p></Show> |
| 82 | <Show when={data().shale}><button class="button secondary" disabled={busy()} onClick={async () => { | 78 | <Show when={catalog()}>{(current) => <> |
| 83 | setBusy(true); | 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> |
| 84 | try { await parseResponse(api.mcp.shale.$delete()); await refetch(); } | 80 | <Show when={current().id === "shale"}> |
| 85 | catch (error) { toast(reason(error)); } | 81 | <section class="mcp-panel"><div class="mcp-section-heading"><h2>Shale account</h2><div class="mcp-actions"> |
| 86 | finally { setBusy(false); } | 82 | <button class="button" disabled={busy()} onClick={linkShale}>{data().shale ? "Relink account" : "Link account"}</button> |
| 87 | }}>Unlink</button></Show> | 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 () => { |
| 88 | </section> | 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 | <h2>Local machines</h2> | 97 | <h2>Local machines</h2> |
| 90 | <p>Run the Agent Relay local agent with this dashboard's origin, then enter its pairing code.</p> | 98 | <p>Install on each machine, then enter the pairing code below. The agent starts at login.</p> |
| 91 | <Copy value={`npm run agent -- run --server ${window.location.origin}`} label="local agent command" /> | 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 | <form class="mcp-actions" onSubmit={async (event) => { | 104 | <form class="mcp-actions" onSubmit={async (event) => { |
| 93 | event.preventDefault(); setBusy(true); | 105 | event.preventDefault(); setBusy(true); |
| 94 | try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } | 106 | try { await parseResponse(api.mcp.relay.pair.$post({ json: { code: code() } })); setCode(""); await refetch(); } |
| ... | @@ -96,7 +108,7 @@ export function MCP() { | ... | @@ -96,7 +108,7 @@ export function MCP() { |
| 96 | finally { setBusy(false); } | 108 | finally { setBusy(false); } |
| 97 | }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label> | 109 | }}><label>Pairing code <input value={code()} onInput={(event) => setCode(event.currentTarget.value)} maxLength={40} /></label> |
| 98 | <button class="button" disabled={!code().trim() || busy()}>Link machine</button></form> | 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 | <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody> | 112 | <table><thead><tr><th>Machine</th><th>Platform</th><th>Connection</th><th /></tr></thead><tbody> |
| 101 | <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td> | 113 | <For each={data().machines}>{(machine) => <tr><td>{machine.name}</td><td>{machine.platform}</td><td>{machine.online ? "Online" : "Offline"}</td><td> |
| 102 | <button class="button small" onClick={async () => { | 114 | <button class="button small" onClick={async () => { |
| ... | @@ -118,19 +130,48 @@ export function MCP() { | ... | @@ -118,19 +130,48 @@ export function MCP() { |
| 118 | <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button> | 130 | <button class="button" disabled={!keyName().trim() || !keyMachines().length || busy()}>Create key</button> |
| 119 | </form> | 131 | </form> |
| 120 | </Show> | 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 | <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> | 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> | 145 | </Show> |
| 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>}> | 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> |
| 124 | <table><thead><tr><th>Client</th><th>Access</th><th>Added</th><th /></tr></thead><tbody> | 147 | <section class="mcp-panel"><h2>Connected clients</h2> |
| 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> | 148 | <Show when={connections().length} fallback={<p class="muted">No clients connected. Add the connector URL to your AI client to get started.</p>}> |
| 126 | <button class="button small" onClick={async () => { | 149 | <div class="mcp-client-list"><For each={connections()}>{(client) => <article class="mcp-client"> |
| 127 | try { await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: connection.id } })); await refetch(); toast("Connection revoked"); } | 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> |
| 128 | catch (error) { toast(reason(error)); } | 151 | <div class="mcp-actions"><button class="button small" disabled={busy()} onClick={() => setEditing(editing() === client.id ? "" : client.id)}>Edit access</button> |
| 129 | }}>Revoke</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 () => { |
| 130 | </td></tr>}</For> | 153 | await parseResponse(api.mcp.connections[":id"].$delete({ param: { id: client.id } })); if (editing() === client.id) setEditing(""); await refetch(); toast("Connection revoked"); |
| 131 | </tbody></table> | 154 | } })}>Revoke</button></div> |
| 132 | </Show> | 155 | <Show when={editing() === client.id}><div class="mcp-edit-access"> |
| 133 | </>} | 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 | </Loaded> | 175 | </Loaded> |
| 135 | </div>; | 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,7 +15,6 @@ import { AppIcon } from "../components/AppIcon.tsx"; |
| 15 | import { Copy } from "../components/Copy.tsx"; | 15 | import { Copy } from "../components/Copy.tsx"; |
| 16 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; | 16 | import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx"; |
| 17 | import { ListPage } from "../components/ListPage.tsx"; | 17 | import { ListPage } from "../components/ListPage.tsx"; |
| 18 | import { OpenApp } from "../components/OpenApp.tsx"; | ||
| 19 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; | 18 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; |
| 20 | import { Reveal } from "../components/Reveal.tsx"; | 19 | import { Reveal } from "../components/Reveal.tsx"; |
| 21 | import { PAGES } from "../components/Sidebar.tsx"; | 20 | import { PAGES } from "../components/Sidebar.tsx"; |
| ... | @@ -24,13 +23,7 @@ import { toast } from "../components/Toast.tsx"; | ... | @@ -24,13 +23,7 @@ import { toast } from "../components/Toast.tsx"; |
| 24 | import { ago, count, date, datetime, plural } from "../format.ts"; | 23 | import { ago, count, date, datetime, plural } from "../format.ts"; |
| 25 | import "./Users.css"; | 24 | import "./Users.css"; |
| 26 | 25 | ||
| 27 | const STEPS: [string, string][] = [ | 26 | const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]]; |
| 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 | ]; | ||
| 34 | 27 | ||
| 35 | const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const; | 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,7 +43,7 @@ const inState = (user: User, state: keyof typeof STATES) => |
| 50 | 43 | ||
| 51 | /** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */ | 44 | /** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */ |
| 52 | function reach(groups: string[], services: ServiceSummary[]) { | 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 | const open = { | 47 | const open = { |
| 55 | apps: apps.filter((app) => canOpen(groups, app.access)), | 48 | apps: apps.filter((app) => canOpen(groups, app.access)), |
| 56 | pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)), | 49 | pages: PAGES.filter((page) => sectionsOf(groups).includes(page.section)), |
| ... | @@ -79,9 +72,9 @@ function groupTip(group: string, d: Data) { | ... | @@ -79,9 +72,9 @@ function groupTip(group: string, d: Data) { |
| 79 | return `${plural(members, "member")} · opens ${opens}`; | 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 | const appName = (clientId: string, services: ServiceSummary[]) => | 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 | /** "active 3h ago in Jellyfin, Shale", from their open sessions. */ | 79 | /** "active 3h ago in Jellyfin, Shale", from their open sessions. */ |
| 87 | function seen(user: User, services: ServiceSummary[]) { | 80 | function seen(user: User, services: ServiceSummary[]) { |
| ... | @@ -98,15 +91,6 @@ function network(ip: string) { | ... | @@ -98,15 +91,6 @@ function network(ip: string) { |
| 98 | if (a === 10 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168)) return "local network"; | 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 | /** Where the list was scrolled when a user page opened, so going back lands in the same place. */ | 94 | /** Where the list was scrolled when a user page opened, so going back lands in the same place. */ |
| 111 | let listScroll = 0; | 95 | let listScroll = 0; |
| 112 | /** Whether the open user page was reached from the list, so "back" can return to it with its filters. */ | 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,13 +188,13 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; |
| 204 | <div class="field"> | 188 | <div class="field"> |
| 205 | first sign-in | 189 | first sign-in |
| 206 | <TabBar label="First sign-in"> | 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 | <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button> | 192 | <button type="button" aria-pressed={!invite()} onClick={() => setInvite(false)}>set a password</button> |
| 209 | </TabBar> | 193 | </TabBar> |
| 210 | </div> | 194 | </div> |
| 211 | <Show when={!invite()}> | 195 | <Show when={!invite()}> |
| 212 | <label class="field">temporary password | 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 | <span class="hint">They pick their own at first sign-in</span> | 198 | <span class="hint">They pick their own at first sign-in</span> |
| 215 | </label> | 199 | </label> |
| 216 | </Show> | 200 | </Show> |
| ... | @@ -219,16 +203,17 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; | ... | @@ -219,16 +203,17 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; |
| 219 | }, | 203 | }, |
| 220 | onConfirm: async (form) => { | 204 | onConfirm: async (form) => { |
| 221 | const text = (name: string) => String(form.get(name) ?? ""); | 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 | json: { | 207 | json: { |
| 224 | profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") }, | 208 | profile: { username: text("username"), email: text("email"), firstName: text("firstName"), lastName: text("lastName") }, |
| 225 | groups: form.getAll("groups").map(String), | 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 | await props.refetch(); | 213 | await props.refetch(); |
| 230 | const user = ready()?.users.find((user) => user.id === id); | 214 | const user = ready()?.users.find((user) => user.id === id); |
| 231 | if (user) open(user); | 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,7 +221,6 @@ function List(props: { data: Resource<Data>; ready: Accessor<Data | undefined>; |
| 236 | <ListPage id="users" flush head={ | 221 | <ListPage id="users" flush head={ |
| 237 | <div class="page-head"> | 222 | <div class="page-head"> |
| 238 | <h1>users</h1> | 223 | <h1>users</h1> |
| 239 | <KeycloakLink services={ready()?.services} path="users" /> | ||
| 240 | <span class="spacer" /> | 224 | <span class="spacer" /> |
| 241 | <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}> | 225 | <button class="button primary" disabled={!ready()} onClick={() => create(ready()!.groups)}> |
| 242 | <UserPlus size={14} />new user | 226 | <UserPlus size={14} />new user |
| ... | @@ -418,7 +402,6 @@ function Person(props: { name: string; data: Resource<Data>; refetch: () => unkn | ... | @@ -418,7 +402,6 @@ function Person(props: { name: string; data: Resource<Data>; refetch: () => unkn |
| 418 | } | 402 | } |
| 419 | 403 | ||
| 420 | function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | 404 | function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 421 | const navigate = useNavigate(); | ||
| 422 | const back = useBack(); | 405 | const back = useBack(); |
| 423 | const param = () => ({ id: props.user.id }); | 406 | const param = () => ({ id: props.user.id }); |
| 424 | const [credentials, credentialActions] = credentialsOf.use(() => props.user.id); | 407 | const [credentials, credentialActions] = credentialsOf.use(() => props.user.id); |
| ... | @@ -450,23 +433,22 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -450,23 +433,22 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 450 | await patch({ enabled }); | 433 | await patch({ enabled }); |
| 451 | if (!enabled) toast(`${props.user.username} can't sign in now`, { label: "undo", run: () => patch({ enabled: true }) }); | 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({ | 436 | const [setupLink, setSetupLink] = createSignal(""); |
| 454 | requiredActions: props.user.requiredActions.includes(step) | 437 | const createLink = () => run("link", async () => { |
| 455 | ? props.user.requiredActions.filter((a) => a !== step) | 438 | const { url } = await parseResponse(api.users[":id"]["setup-link"].$post({param:param()})); |
| 456 | : [...props.user.requiredActions, step], | 439 | setSetupLink(url); |
| 457 | })); | 440 | }); |
| 458 | const emailSteps = () => run("email", async () => { | 441 | const revokeLink = () => run("link", async () => { |
| 459 | await parseResponse(api.users[":id"]["actions-email"].$post({ param: param() })); | 442 | await parseResponse(api.users[":id"]["setup-link"].$delete({param:param()})); |
| 460 | toast(`Emailed ${props.user.email} a link`); | 443 | setSetupLink(""); toast("Revoked the setup link"); |
| 461 | }); | 444 | }); |
| 462 | 445 | ||
| 463 | const setPassword = () => showTextDialog({ | 446 | const setPassword = () => showConfirmDialog({ |
| 464 | title: `Set ${props.user.username}'s password`, | 447 | title: `Set ${props.user.username}'s password`, |
| 465 | label: "new password, at least 8 characters", | 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></>, |
| 466 | body: <Checkbox name="temporary" checked>ask for a new one at next sign-in</Checkbox>, | ||
| 467 | confirmLabel: "set password", | 449 | confirmLabel: "set password", |
| 468 | validateInput: (value) => value.length >= 8, | 450 | onConfirm: async (form) => { |
| 469 | onConfirm: async (password, form) => { | 451 | const password = String(form.get("password") ?? ""); |
| 470 | await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } })); | 452 | await parseResponse(api.users[":id"].password.$put({ param: param(), json: { password, temporary: form.has("temporary") } })); |
| 471 | await Promise.all([props.refetch(), credentialActions.refetch()]); | 453 | await Promise.all([props.refetch(), credentialActions.refetch()]); |
| 472 | }, | 454 | }, |
| ... | @@ -484,7 +466,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -484,7 +466,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 484 | const name = props.user.username; | 466 | const name = props.user.username; |
| 485 | showTextDialog({ | 467 | showTextDialog({ |
| 486 | title: `Delete ${name}?`, | 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 | label: `type ${name} to confirm`, | 470 | label: `type ${name} to confirm`, |
| 489 | validateInput: (value) => value === name, | 471 | validateInput: (value) => value === name, |
| 490 | confirmLabel: "delete user", | 472 | confirmLabel: "delete user", |
| ... | @@ -499,7 +481,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -499,7 +481,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 499 | }; | 481 | }; |
| 500 | 482 | ||
| 501 | const access = () => reach(props.user.enabled ? names(props.user) : [], props.data.services); | 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 | const used = () => { | 485 | const used = () => { |
| 504 | const last = new Map<string, number>(); | 486 | const last = new Map<string, number>(); |
| 505 | for (const session of props.user.sessions) { | 487 | for (const session of props.user.sessions) { |
| ... | @@ -517,7 +499,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -517,7 +499,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 517 | <h1>{props.user.username}</h1> | 499 | <h1>{props.user.username}</h1> |
| 518 | <span class="sub">{fullName(props.user)}</span> | 500 | <span class="sub">{fullName(props.user)}</span> |
| 519 | <StateTag user={props.user} /> | 501 | <StateTag user={props.user} /> |
| 520 | <KeycloakLink services={props.data.services} path={`users/${props.user.id}/settings`} /> | ||
| 521 | <span class="spacer" /> | 502 | <span class="spacer" /> |
| 522 | <button class="button danger" onClick={remove}>delete user</button> | 503 | <button class="button danger" onClick={remove}>delete user</button> |
| 523 | </div> | 504 | </div> |
| ... | @@ -556,7 +537,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -556,7 +537,6 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 556 | {(list) => { | 537 | {(list) => { |
| 557 | const password = () => list().find((c) => c.type === "password"); | 538 | const password = () => list().find((c) => c.type === "password"); |
| 558 | const passkeys = () => list().filter((c) => c.type.startsWith("webauthn")); | 539 | const passkeys = () => list().filter((c) => c.type.startsWith("webauthn")); |
| 559 | const otp = () => list().find((c) => c.type === "otp"); | ||
| 560 | return ( | 540 | return ( |
| 561 | <dl class="kv"> | 541 | <dl class="kv"> |
| 562 | <dt>password</dt> | 542 | <dt>password</dt> |
| ... | @@ -567,33 +547,15 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -567,33 +547,15 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 567 | {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>} | 547 | {(c) => <span tabindex="0" data-tip={`added ${date(c.createdDate / 1000)}`}>{c.userLabel ?? "unnamed"}</span>} |
| 568 | </For> | 548 | </For> |
| 569 | </dd> | 549 | </dd> |
| 570 | <dt>authenticator</dt> | ||
| 571 | <dd>{otp() ? `added ${date(otp()!.createdDate / 1000)}` : "none"}</dd> | ||
| 572 | </dl> | 550 | </dl> |
| 573 | ); | 551 | ); |
| 574 | }} | 552 | }} |
| 575 | </Loaded> | 553 | </Loaded> |
| 576 | <h2 class="card-title opens"> | 554 | <h2 class="card-title opens">setup link</h2> |
| 577 | next sign-in | 555 | <p class="muted">One use, valid for 24 hours. A new link replaces the previous one.</p> |
| 578 | <span class="spacer" /> | 556 | <button class="button small" disabled={!props.user.enabled || busy() === "link"} onClick={createLink}>create setup link</button>{" "} |
| 579 | <button class="button small" disabled={!props.user.email || !props.user.requiredActions.length || busy() === "email"} | 557 | <button class="button small" disabled={busy() === "link"} onClick={revokeLink}>revoke link</button> |
| 580 | aria-busy={busy() === "email"} | 558 | <Show when={setupLink()}><p style={{"overflow-wrap":"anywhere"}}><Copy value={setupLink()} /></p></Show> |
| 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> | ||
| 597 | </section> | 559 | </section> |
| 598 | </div> | 560 | </div> |
| 599 | 561 | ||
| ... | @@ -601,11 +563,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -601,11 +563,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 601 | <section class="card"> | 563 | <section class="card"> |
| 602 | <h2 class="card-title">profile</h2> | 564 | <h2 class="card-title">profile</h2> |
| 603 | <div class="fields"> | 565 | <div class="fields"> |
| 604 | <Field label="username" value={props.user.username} onSave={async (username) => { | 566 | <span class="label">username</span><span>{props.user.username}</span> |
| 605 | await parseResponse(api.users[":id"].$patch({ param: param(), json: { username } })); | ||
| 606 | await props.refetch(); | ||
| 607 | navigate(`/users/${username.trim().toLowerCase()}`, { replace: true }); | ||
| 608 | }} /> | ||
| 609 | <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} /> | 567 | <Field label="email" type="email" value={props.user.email} onSave={(email) => patch({ email })} /> |
| 610 | <span /> | 568 | <span /> |
| 611 | <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"} | 569 | <Checkbox checked={props.user.emailVerified} disabled={busy() === "verified"} |
dashboard/web/types/mcp.ts+11-3| ... | @@ -1,12 +1,20 @@ | ... | @@ -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 | export interface Connections { | 5 | export interface Connections { |
| 2 | catalogs: { name: string; endpoint: string }[]; | 6 | catalogs: { id: Catalog; name: string; endpoint: string }[]; |
| 3 | connections: { id: string; name: string; resources: string[]; scopes: string[]; createdAt: number }[]; | 7 | connections: { id: string; name: string; catalog: Catalog; resources: Access; scopes: string[]; createdAt: number }[]; |
| 4 | machines: { id: string; name: string; platform: string; online: boolean }[]; | 8 | machines: { id: string; name: string; platform: string; online: boolean }[]; |
| 5 | shale: { linkedAt: number } | null; | 9 | shale: { linkedAt: number } | null; |
| 6 | } | 10 | } |
| 7 | export interface Consent { | 11 | export interface Consent { |
| 8 | linked: boolean; | 12 | linked: boolean; |
| 9 | client: string; | 13 | client: string; |
| 14 | catalog: Catalog; | ||
| 15 | account: string; | ||
| 16 | redirectHost: string; | ||
| 10 | scopes: string[]; | 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,4 +1,3 @@ |
| 1 | /** Field names and millisecond timestamps follow Keycloak's admin representations. */ | ||
| 2 | export interface User { | 1 | export interface User { |
| 3 | id: string; | 2 | id: string; |
| 4 | username: string; | 3 | username: string; |
| ... | @@ -10,7 +9,6 @@ export interface User { | ... | @@ -10,7 +9,6 @@ export interface User { |
| 10 | createdTimestamp: number; | 9 | createdTimestamp: number; |
| 11 | requiredActions: string[]; | 10 | requiredActions: string[]; |
| 12 | groups: Group[]; | 11 | groups: Group[]; |
| 13 | /** Keycloak replaces the whole map on update, so send it merged. */ | ||
| 14 | attributes?: Record<string, string[]>; | 12 | attributes?: Record<string, string[]>; |
| 15 | } | 13 | } |
| 16 | 14 | ||
| ... | @@ -31,6 +29,5 @@ export interface Session { | ... | @@ -31,6 +29,5 @@ export interface Session { |
| 31 | ipAddress: string; | 29 | ipAddress: string; |
| 32 | start: number; | 30 | start: number; |
| 33 | lastAccess: number; | 31 | lastAccess: number; |
| 34 | /** Client UUID to `clientId`. */ | ||
| 35 | clients: Record<string, string>; | 32 | clients: Record<string, string>; |
| 36 | } | 33 | } |
nixos/configuration.nix+3| ... | @@ -158,6 +158,9 @@ in | ... | @@ -158,6 +158,9 @@ in |
| 158 | STUDIO_INDEX_DIR = "/data/index"; | 158 | STUDIO_INDEX_DIR = "/data/index"; |
| 159 | STUDIO_INTERNAL_URL = "https://dashboard.internal.${config.environment.variables.STUDIO_DOMAIN}:${toString internalPort}"; | 159 | STUDIO_INTERNAL_URL = "https://dashboard.internal.${config.environment.variables.STUDIO_DOMAIN}:${toString internalPort}"; |
| 160 | STUDIO_FILES_URL = "https://file.${config.environment.variables.STUDIO_DOMAIN}"; | 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 | STUDIO_PUBLIC_ORIGIN = "https://snowglobe.${config.environment.variables.STUDIO_DOMAIN}"; | 164 | STUDIO_PUBLIC_ORIGIN = "https://snowglobe.${config.environment.variables.STUDIO_DOMAIN}"; |
| 162 | STUDIO_KEYCLOAK_URL = "https://auth.${config.environment.variables.STUDIO_DOMAIN}"; | 165 | STUDIO_KEYCLOAK_URL = "https://auth.${config.environment.variables.STUDIO_DOMAIN}"; |
| 163 | STUDIO_JELLYFIN_URL = "https://jelly.${config.environment.variables.STUDIO_DOMAIN}"; | 166 | STUDIO_JELLYFIN_URL = "https://jelly.${config.environment.variables.STUDIO_DOMAIN}"; |
nixos/dashboard.nix+4-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -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 | let | 2 | let |
| 3 | nativePkl = callPackage ./pkl.nix { }; | 3 | nativePkl = callPackage ./pkl.nix { }; |
| 4 | youtubePython = python3.withPackages (packages: [ packages.pyyaml ]); | 4 | youtubePython = python3.withPackages (packages: [ packages.pyyaml ]); |
| ... | @@ -38,11 +38,12 @@ let | ... | @@ -38,11 +38,12 @@ let |
| 38 | root = ../dashboard; | 38 | root = ../dashboard; |
| 39 | fileset = lib.fileset.unions [ | 39 | fileset = lib.fileset.unions [ |
| 40 | ../dashboard/src ../dashboard/tests ../dashboard/Cargo.toml ../dashboard/Cargo.lock | 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 | cargoLock.lockFile = ../dashboard/Cargo.lock; | 44 | cargoLock.lockFile = ../dashboard/Cargo.lock; |
| 44 | nativeBuildInputs = [ pkg-config ]; | 45 | nativeBuildInputs = [ pkg-config ]; |
| 45 | buildInputs = [ sqlite ]; | 46 | buildInputs = [ sqlite openssl ]; |
| 46 | LIBSQLITE3_SYS_USE_PKG_CONFIG = "1"; | 47 | LIBSQLITE3_SYS_USE_PKG_CONFIG = "1"; |
| 47 | }; | 48 | }; |
| 48 | dashboard = runCommand "home-dashboard-0.1.0" { | 49 | dashboard = runCommand "home-dashboard-0.1.0" { |
| ... | @@ -67,5 +68,6 @@ let | ... | @@ -67,5 +68,6 @@ let |
| 67 | ln -s ${server}/bin/home-dashboard $out/bin/home-dashboard | 68 | ln -s ${server}/bin/home-dashboard $out/bin/home-dashboard |
| 68 | ln -s ${web} $out/lib/home-dashboard/dist | 69 | ln -s ${web} $out/lib/home-dashboard/dist |
| 69 | ln -s ${../dashboard/server} $out/lib/home-dashboard/server | 70 | ln -s ${../dashboard/server} $out/lib/home-dashboard/server |
| 71 | ln -s ${../dashboard/agent} $out/lib/home-dashboard/agent | ||
| 70 | ''; | 72 | ''; |
| 71 | in dashboard | 73 | in dashboard |
readme.md+109-1| ... | @@ -123,8 +123,24 @@ root, private network, resource limits, and explicit data mounts. The small | ... | @@ -123,8 +123,24 @@ root, private network, resource limits, and explicit data mounts. The small |
| 123 | performs bounded ZFS, VM, deployment, host-sampling, and identity operations. | 123 | performs bounded ZFS, VM, deployment, host-sampling, and identity operations. |
| 124 | Host control sockets and management credentials stay outside the container. | 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 | The MCP tab manages separate observability, agent, and Shale catalogs through | 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 | repository grants; Shale credentials belong to the signed-in user. Agent Relay's | 144 | repository grants; Shale credentials belong to the signed-in user. Agent Relay's |
| 129 | existing outbound client protocol connects to the Rust server. | 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,3 +150,95 @@ rehearsal VM to exercise the generated NixOS units, containment, IAM, and MCP |
| 134 | connectors with disposable fixtures. `--relay-agent-dir` includes the existing | 150 | connectors with disposable fixtures. `--relay-agent-dir` includes the existing |
| 135 | Agent Relay client interoperability check; `--browser-ready-file` temporarily | 151 | Agent Relay client interoperability check; `--browser-ready-file` temporarily |
| 136 | routes the public dashboard to the fixture for browser and SSO load checks. | 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,12 +15,13 @@ |
| 15 | 15 | ||
| 16 | xff-src: lan | 16 | xff-src: lan |
| 17 | rproxy: 1 | 17 | rproxy: 1 |
| 18 | auth-ord: pw,idp,ipu | ||
| 18 | idp-h-usr: user-name | 19 | idp-h-usr: user-name |
| 19 | idp-h-grp: user-groups | 20 | idp-h-grp: user-groups |
| 20 | idp-h-key: STUDIO_IDP_HEADER | 21 | idp-h-key: STUDIO_IDP_HEADER |
| 21 | idp-login: /snow.oauth2/sign_in?rd={dst} | 22 | idp-login: /auth/file/sign-in?rd={dst} |
| 22 | idp-logout: /snow.oauth2/sign_out | 23 | idp-logout: /auth/file/sign-out |
| 23 | idp-login-t: with sso (snow sign on) | 24 | idp-login-t: with snow globe |
| 24 | html-head: <link rel="stylesheet" href="/.static/copyparty.css"> | 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,7 +67,7 @@ def main(): |
| 67 | request = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], | 67 | request = {"response_type": "code", "client_id": client["client_id"], "redirect_uri": client["redirect_uris"][0], |
| 68 | "code_challenge_method": "S256", "code_challenge": challenge, "resource": resource, "scope": scope, "state": marker} | 68 | "code_challenge_method": "S256", "code_challenge": challenge, "resource": resource, "scope": scope, "state": marker} |
| 69 | _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(request), status=302) | 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 | path = "/api/mcp/consent/" + pending | 71 | path = "/api/mcp/consent/" + pending |
| 72 | details, _ = http(path, actor=actor) | 72 | details, _ = http(path, actor=actor) |
| 73 | assert details["client"] == client["client_name"] | 73 | assert details["client"] == client["client_name"] |
tools/dashboard-relay-test.py+1-1| ... | @@ -306,7 +306,7 @@ def main(): | ... | @@ -306,7 +306,7 @@ def main(): |
| 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", | 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 | "code_challenge_method": "S256", "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")} | 307 | "code_challenge_method": "S256", "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")} |
| 308 | _, headers = http("/oauth/authorize?" + urllib.parse.urlencode(fields), status=302) | 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 | consent_path = "/api/mcp/consent/" + request_id | 310 | consent_path = "/api/mcp/consent/" + request_id |
| 311 | details, _ = http(consent_path, actor=names[0]) | 311 | details, _ = http(consent_path, actor=names[0]) |
| 312 | assert {r["id"] for r in details["resources"]} == {first["id"], second["id"]} | 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,6 +49,7 @@ def main(): |
| 49 | marker = 'shale-link-' + uuid.uuid4().hex | 49 | marker = 'shale-link-' + uuid.uuid4().hex |
| 50 | accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')] | 50 | accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')] |
| 51 | ids = [] | 51 | ids = [] |
| 52 | sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts} | ||
| 52 | 53 | ||
| 53 | class TLS(urllib.request.HTTPSHandler): | 54 | class TLS(urllib.request.HTTPSHandler): |
| 54 | def https_open(self, request): | 55 | def https_open(self, request): |
| ... | @@ -76,6 +77,7 @@ def main(): | ... | @@ -76,6 +77,7 @@ def main(): |
| 76 | _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method, | 77 | _, _, body = request(urllib.request.build_opener(NoRedirect()), args.url + path, method, |
| 77 | body=json.dumps(body).encode() if body is not None else None, | 78 | body=json.dumps(body).encode() if body is not None else None, |
| 78 | headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor, | 79 | headers={'Host': 'globe.studio.test', 'Studio-Proxy-Token': proof, 'User-Name': actor, |
| 80 | 'Cookie': '__Host-snow-session=' + sessions[actor], | ||
| 79 | 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status) | 81 | 'User-Groups': '', 'Origin': origin, 'Content-Type': 'application/json'}, status=status) |
| 80 | return json.loads(body) if body and status < 400 else body or None | 82 | return json.loads(body) if body and status < 400 else body or None |
| 81 | 83 | ||
| ... | @@ -132,7 +134,7 @@ def main(): | ... | @@ -132,7 +134,7 @@ def main(): |
| 132 | assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', [])) | 134 | assert all(not cookie.startswith('SessionID=') for cookie in headers.get_all('Set-Cookie', [])) |
| 133 | assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', [])) | 135 | assert any('studio_mcp_shale_link=;' in cookie and 'Max-Age=0' in cookie for cookie in headers.get_all('Set-Cookie', [])) |
| 134 | if status == 303: | 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 | def backend_session(value, status): | 139 | def backend_session(value, status): |
| 138 | return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status) | 140 | return request(browser()[0], args.shale_origin + '/-/settings', headers={'Cookie': 'SessionID=' + value['session']}, status=status) |
| ... | @@ -171,7 +173,7 @@ def main(): | ... | @@ -171,7 +173,7 @@ def main(): |
| 171 | _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code', | 173 | _, headers = public('/oauth/authorize?' + urllib.parse.urlencode({'response_type': 'code', |
| 172 | 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256', | 174 | 'client_id': client['client_id'], 'redirect_uri': client['redirect_uris'][0], 'code_challenge_method': 'S256', |
| 173 | 'code_challenge': challenge, 'resource': origin + '/mcp/shale', 'scope': scope, 'state': marker}), status=302) | 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 | details = api(accounts[index][0], path='/api/mcp/consent/' + pending) | 177 | details = api(accounts[index][0], path='/api/mcp/consent/' + pending) |
| 176 | assert details['client'] == marker | 178 | assert details['client'] == marker |
| 177 | api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403) | 179 | api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403) |
| ... | @@ -180,13 +182,13 @@ def main(): | ... | @@ -180,13 +182,13 @@ def main(): |
| 180 | def consent(client, index, repository, scope): | 182 | def consent(client, index, repository, scope): |
| 181 | pending, verifier, details = pending_request(client, index, scope) | 183 | pending, verifier, details = pending_request(client, index, scope) |
| 182 | available = {r['id'] for r in details['resources']} | 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 | if index == 0: | 186 | if index == 0: |
| 185 | assert available == {'alpha', 'beta'}, details | 187 | assert {'alpha', 'beta'} <= available, details |
| 186 | path = '/api/mcp/consent/' + pending | 188 | path = '/api/mcp/consent/' + pending |
| 187 | api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']}) | 189 | api(accounts[index][0], 'POST', path, 403, {'resources': ['outside-grant']}) |
| 188 | api(accounts[index][0], 'POST', path, 403 if len(available) >= 2 else 400, {'resources': [repository, repository]}) | 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 | query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query) | 192 | query = urllib.parse.parse_qs(urllib.parse.urlsplit(result['redirect']).query) |
| 191 | assert query['state'] == [marker] | 193 | assert query['state'] == [marker] |
| 192 | tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'], | 194 | tokens, _ = public('/oauth/token', 'POST', {'grant_type': 'authorization_code', 'client_id': client['client_id'], |
| ... | @@ -245,6 +247,13 @@ def main(): | ... | @@ -245,6 +247,13 @@ def main(): |
| 245 | found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true') | 247 | found = keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true') |
| 246 | assert len(found) == 1 | 248 | assert len(found) == 1 |
| 247 | ids.append(found[0]['id']) | 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 | assert all(api(name)['shale'] is None for name, _ in accounts) | 257 | assert all(api(name)['shale'] is None for name, _ in accounts) |
| 249 | api(accounts[0][0], 'POST', '/api/mcp/shale', status=200) | 258 | api(accounts[0][0], 'POST', '/api/mcp/shale', status=200) |
| 250 | old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect'] | 259 | old_target = api(accounts[0][0], 'POST', '/api/mcp/shale')['redirect'] |
| ... | @@ -288,7 +297,37 @@ def main(): | ... | @@ -288,7 +297,37 @@ def main(): |
| 288 | 'comment_issue', 'set_issue_status', 'set_issue_title'} | 297 | 'comment_issue', 'set_issue_status', 'set_issue_title'} |
| 289 | assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools) | 298 | assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools) |
| 290 | assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'} | 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 | assert {repo['id'] for repo in call(other['access_token'], 'list_repositories')['repositories']} == {'foreign'} | 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 | assert not call(read, 'list_issues', {'repository': 'alpha'})['issues'] | 331 | assert not call(read, 'list_issues', {'repository': 'alpha'})['issues'] |
| 293 | for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'), | 332 | for token, name in [(read, 'beta'), (read, 'foreign'), (other['access_token'], 'alpha'), |
| 294 | (write, 'alpha/../foreign'), (write, 'https://other.invalid')]: | 333 | (write, 'alpha/../foreign'), (write, 'https://other.invalid')]: |
| ... | @@ -392,6 +431,7 @@ def main(): | ... | @@ -392,6 +431,7 @@ def main(): |
| 392 | 'native_issue_labels_read': True, | 431 | 'native_issue_labels_read': True, |
| 393 | 'committed_write_lost_response_reported_without_replay': True, | 432 | 'committed_write_lost_response_reported_without_replay': True, |
| 394 | 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True, | 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 | 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True} | 435 | 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True} |
| 396 | finally: | 436 | finally: |
| 397 | keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1) | 437 | keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1) |
| ... | @@ -408,6 +448,12 @@ def main(): | ... | @@ -408,6 +448,12 @@ def main(): |
| 408 | keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE') | 448 | keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE') |
| 409 | except Exception as error: | 449 | except Exception as error: |
| 410 | cleanup_errors.append(error) | 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 | if cleanup_errors: | 457 | if cleanup_errors: |
| 412 | raise cleanup_errors[0] | 458 | raise cleanup_errors[0] |
| 413 | result['owned_users_and_credentials_removed'] = True | 459 | result['owned_users_and_credentials_removed'] = True |
tools/data.py+47-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | import argparse | 2 | import argparse |
| 3 | from contextlib import contextmanager | 3 | from contextlib import closing, contextmanager |
| 4 | from datetime import datetime, timezone | 4 | from datetime import datetime, timezone |
| 5 | import hashlib | 5 | import hashlib |
| 6 | import json | 6 | import json |
| ... | @@ -9,6 +9,7 @@ from pathlib import Path | ... | @@ -9,6 +9,7 @@ from pathlib import Path |
| 9 | import re | 9 | import re |
| 10 | import secrets | 10 | import secrets |
| 11 | import shutil | 11 | import shutil |
| 12 | import sqlite3 | ||
| 12 | import subprocess | 13 | import subprocess |
| 13 | import time | 14 | import time |
| 14 | 15 | ||
| ... | @@ -140,6 +141,28 @@ def cloned_postgres(snapshot, clone, mountpoint): | ... | @@ -140,6 +141,28 @@ def cloned_postgres(snapshot, clone, mountpoint): |
| 140 | run("zfs", "destroy", clone) | 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 | def backup(from_release, to_release): | 166 | def backup(from_release, to_release): |
| 144 | if not RELEASE_ID.fullmatch(from_release) or not RELEASE_ID.fullmatch(to_release): | 167 | if not RELEASE_ID.fullmatch(from_release) or not RELEASE_ID.fullmatch(to_release): |
| 145 | raise ValueError("Invalid release ID") | 168 | raise ValueError("Invalid release ID") |
| ... | @@ -177,6 +200,8 @@ def backup(from_release, to_release): | ... | @@ -177,6 +200,8 @@ def backup(from_release, to_release): |
| 177 | snapshots = [f"{dataset}@{snapshot}" for dataset in sorted(datasets)] | 200 | snapshots = [f"{dataset}@{snapshot}" for dataset in sorted(datasets)] |
| 178 | created = False | 201 | created = False |
| 179 | try: | 202 | try: |
| 203 | if (STATE / "dashboard").is_dir(): | ||
| 204 | manifest["services"]["dashboard"] = {"files": backup_dashboard(directory / "dashboard")} | ||
| 180 | if snapshots: | 205 | if snapshots: |
| 181 | run("zfs", "snapshot", *snapshots) | 206 | run("zfs", "snapshot", *snapshots) |
| 182 | created = True | 207 | created = True |
| ... | @@ -231,6 +256,27 @@ def restore(backup_id, service): | ... | @@ -231,6 +256,27 @@ def restore(backup_id, service): |
| 231 | if service == "postgres": | 256 | if service == "postgres": |
| 232 | raise ValueError("Postgres serves multiple services; restore a specific database owner") | 257 | raise ValueError("Postgres serves multiple services; restore a specific database owner") |
| 233 | entry = manifest["services"][service] | 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 | dataset = entry.get("dataset") | 280 | dataset = entry.get("dataset") |
| 235 | if dataset and dataset_for(service) != dataset: | 281 | if dataset and dataset_for(service) != dataset: |
| 236 | raise ValueError("Service dataset changed since backup") | 282 | raise ValueError("Service dataset changed since backup") |
tools/deploy.py+3| ... | @@ -2,6 +2,7 @@ | ... | @@ -2,6 +2,7 @@ |
| 2 | import argparse | 2 | import argparse |
| 3 | import json | 3 | import json |
| 4 | import os | 4 | import os |
| 5 | from importlib import import_module | ||
| 5 | from pathlib import Path | 6 | from pathlib import Path |
| 6 | import re | 7 | import re |
| 7 | import shlex | 8 | import shlex |
| ... | @@ -113,6 +114,8 @@ def upload(main=False): | ... | @@ -113,6 +114,8 @@ def upload(main=False): |
| 113 | shutil.copytree(origin, destination, symlinks=True) | 114 | shutil.copytree(origin, destination, symlinks=True) |
| 114 | else: | 115 | else: |
| 115 | shutil.copy2(origin, destination) | 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 | digest = tree_digest(snapshot) | 119 | digest = tree_digest(snapshot) |
| 117 | release = digest[:16] | 120 | release = digest[:16] |
| 118 | remote_release = REMOTE / "releases" / release | 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,6 +50,10 @@ def render(token): |
| 50 | dashboard_proof = file.read().strip() | 50 | dashboard_proof = file.read().strip() |
| 51 | if not re.fullmatch(r"[0-9a-fA-F]{64}", dashboard_proof): | 51 | if not re.fullmatch(r"[0-9a-fA-F]{64}", dashboard_proof): |
| 52 | raise ValueError("invalid dashboard proxy token") | 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 | routes = {} | 57 | routes = {} |
| 54 | internal_services = {} | 58 | internal_services = {} |
| 55 | auth_upstreams = set() | 59 | auth_upstreams = set() |
| ... | @@ -130,7 +134,11 @@ def render(token): | ... | @@ -130,7 +134,11 @@ def render(token): |
| 130 | service = next(iter(route["services"])) | 134 | service = next(iter(route["services"])) |
| 131 | if not re.fullmatch(r"[a-z][a-z0-9-]*", service): | 135 | if not re.fullmatch(r"[a-z][a-z0-9-]*", service): |
| 132 | raise ValueError(f"invalid service name: {service}") | 136 | raise ValueError(f"invalid service name: {service}") |
| 137 | if service == "keycloak" and host == auth_host: | ||
| 138 | lines += webauthn | ||
| 133 | if service == "shale": | 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 | port = int(os.environ["STUDIO_DASHBOARD_PORT"]) | 142 | port = int(os.environ["STUDIO_DASHBOARD_PORT"]) |
| 135 | if not 1 <= port <= 65535: | 143 | if not 1 <= port <= 65535: |
| 136 | raise ValueError("invalid dashboard port for Shale linking") | 144 | raise ValueError("invalid dashboard port for Shale linking") |
| ... | @@ -203,11 +211,19 @@ def render(token): | ... | @@ -203,11 +211,19 @@ def render(token): |
| 203 | *proxy(upstreams, " "), " }", | 211 | *proxy(upstreams, " "), " }", |
| 204 | " handle_response {", " respond 403", " }", " }", " }", "}", | 212 | " handle_response {", " respond 403", " }", " }", " }", "}", |
| 205 | ] | 213 | ] |
| 206 | elif headers and auth_upstreams: | 214 | elif headers and (auth_upstreams or service == "copyparty"): |
| 207 | auth = " ".join(sorted(auth_upstreams)) | 215 | native = service == "copyparty" |
| 208 | lines += [" handle /snow.oauth2/* {", *proxy(auth, " "), " }", " handle {"] | 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 | lines += [f" request_header -{name}" for name in scrub] | 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 | " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}", | 227 | " header_up X-Forwarded-Method {method}", " header_up X-Forwarded-Uri {uri}", |
| 212 | " @authenticated status 2xx", " handle_response @authenticated {"] | 228 | " @authenticated status 2xx", " handle_response @authenticated {"] |
| 213 | lines += [f" request_header {name} {{rp.header.{source}}}" for name, source in headers.items()] | 229 | lines += [f" request_header {name} {{rp.header.{source}}}" for name, source in headers.items()] |
| ... | @@ -227,6 +243,11 @@ def render(token): | ... | @@ -227,6 +243,11 @@ def render(token): |
| 227 | *proxy(upstreams, " "), " }", "}"] | 243 | *proxy(upstreams, " "), " }", "}"] |
| 228 | else: | 244 | else: |
| 229 | lines += [f":{listener} {{", *proxy(" ".join(sorted(route["upstreams"])), " "), "}"] | 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 | traces = next((route for route in routes.values() if "victoria-traces" in route["services"]), None) | 251 | traces = next((route for route in routes.values() if "victoria-traces" in route["services"]), None) |
| 231 | if traces: | 252 | if traces: |
| 232 | lines += ["http://127.0.0.1:10428 {", " bind 127.0.0.1", | 253 | lines += ["http://127.0.0.1:10428 {", " bind 127.0.0.1", |
| ... | @@ -241,30 +262,10 @@ def render(token): | ... | @@ -241,30 +262,10 @@ def render(token): |
| 241 | if dashboard_host.endswith(".test"): | 262 | if dashboard_host.endswith(".test"): |
| 242 | lines.append(" tls internal") | 263 | lines.append(" tls internal") |
| 243 | lines += [" encode zstd gzip", " tracing {", " span globe", " span_attributes {", " studio.kind edge", " }", " }"] | 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/*", | 265 | lines += [" handle {", " request_header -User-Name", " request_header -User-Groups", |
| 245 | " handle @mcp_public {", " request_header -User-Name", " request_header -User-Groups", | 266 | f" request_header Studio-Proxy-Token {dashboard_proof}", |
| 246 | " request_header -Studio-Proxy-Token", *proxy(f"127.0.0.1:{dashboard_port}", " "), " }"] | 267 | " request_header X-Studio-Client-IP {remote_host}", |
| 247 | if auth_upstreams: | 268 | *proxy(f"127.0.0.1:{dashboard_port}", " "), " }", "}"] |
| 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', "}"] | ||
| 268 | internal_port = os.environ.get("STUDIO_INTERNAL_PORT") | 269 | internal_port = os.environ.get("STUDIO_INTERNAL_PORT") |
| 269 | if internal_port is not None: | 270 | if internal_port is not None: |
| 270 | internal_host = "dashboard.internal." + os.environ["STUDIO_DOMAIN"] | 271 | internal_host = "dashboard.internal." + os.environ["STUDIO_DOMAIN"] |