diff --git a/dashboard/.gitignore b/dashboard/.gitignore index c9b60c56f8344e5229a9ef30ed024babbcbbc819..2925148a23320aa72f16daa5d6e8ee24b2a5a3ad 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -3,3 +3,4 @@ dist/ .cache/ data/ target/ +agent/relay.mjs diff --git a/dashboard/Cargo.lock b/dashboard/Cargo.lock index 5625afbec5c4d972b7fdcffa373943742a7398bc..9fc05f11cb81d84eeb56cfe17e2d7a151ed563db 100644 --- a/dashboard/Cargo.lock +++ b/dashboard/Cargo.lock @@ -20,6 +20,57 @@ dependencies = [ "libc", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -99,6 +150,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -111,12 +168,38 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey 0.1.1", + "serde", +] + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -225,6 +308,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -264,6 +353,26 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "2.1.1" @@ -293,6 +402,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -393,6 +503,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -552,6 +677,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -576,10 +712,17 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home-dashboard" version = "0.1.0" dependencies = [ + "argon2", "axum", "base64 0.22.1", "bytes", @@ -592,6 +735,8 @@ dependencies = [ "rmcp", "rusqlite", "scraper", + "serde", + "serde_cbor_2", "serde_json", "sha1", "sha2", @@ -602,6 +747,7 @@ dependencies = [ "url", "uuid", "walkdir", + "webauthn-rs", ] [[package]] @@ -889,6 +1035,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -972,6 +1124,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.3" @@ -1012,6 +1170,41 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1021,12 +1214,58 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1050,6 +1289,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.3" @@ -1136,6 +1392,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1174,7 +1436,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -1196,7 +1458,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -1268,6 +1530,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -1419,14 +1687,14 @@ dependencies = [ "http-body", "http-body-util", "indexmap", - "pastey", + "pastey 0.2.3", "pin-project-lite", "rand 0.10.3", "schemars", "serde", "serde_json", "sse-stream", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -1464,6 +1732,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustls" version = "0.23.45" @@ -1601,6 +1878,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1835,6 +2122,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -1855,13 +2153,33 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1875,6 +2193,36 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2073,7 +2421,7 @@ dependencies = [ "log", "rand 0.9.5", "sha1", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -2110,6 +2458,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2126,6 +2475,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2263,6 +2613,74 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.5", + "rand_chacha", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2434,6 +2852,23 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2454,7 +2889,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "synstructure", + "synstructure 0.14.0", ] [[package]] @@ -2495,7 +2930,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.6", - "synstructure", + "synstructure 0.14.0", ] [[package]] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 430eb082959dd61a6413c1a308de3475e1d363da..32b52b5dee99a6a334ea2b1df728ed4c41bec176 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +argon2 = "0.5" axum = { version = "0.8.9", features = ["multipart", "ws"] } base64 = "0.22" bytes = "1" @@ -15,6 +16,8 @@ regex = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] } rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] } rusqlite = { version = "0.37", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_cbor_2 = "0.13" scraper = { version = "0.27", default-features = false } serde_json = "1" sha1 = "0.10" @@ -26,6 +29,7 @@ tower-http = { version = "0.6", features = ["fs"] } url = "2" uuid = { version = "1", features = ["v4"] } walkdir = "2" +webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } [profile.release] lto = "thin" diff --git a/dashboard/agent/install.ps1 b/dashboard/agent/install.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..41f1caaa7c69037074ba196b0e3241e81621d59e --- /dev/null +++ b/dashboard/agent/install.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Stop' +$Server = __SERVER__ + +function Install-Agent { + $Identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $Principal = New-Object Security.Principal.WindowsPrincipal($Identity) + if ($Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this installer from your usual PowerShell window, without administrator privileges.' + } + $Base = Join-Path $env:LOCALAPPDATA 'AgentRelay' + $Stage = Join-Path $Base ('.install.' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force $Base | Out-Null + & icacls.exe $Base /inheritance:r /grant:r ('*' + $Identity.User.Value + ':(OI)(CI)F') '*S-1-5-18:(OI)(CI)F' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Unable to protect the agent folder. Check its permissions and retry.' } + try { + New-Item -ItemType Directory -Force $Stage | Out-Null + $Node = Join-Path $Base 'node.exe' + if (!(Test-Path $Node)) { + Write-Host 'Downloading the agent runtime…' + $Arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { 'arm64' } else { 'x64' } + $Checksums = (Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 'https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt').Content + $Match = [regex]::Match($Checksums, "(?m)^([a-f0-9]{64})\s+(node-(v24\.[0-9]+\.[0-9]+)-win-$Arch\.zip)\s*$") + if (!$Match.Success) { throw 'No runtime download is available for this machine.' } + $Archive = Join-Path $Stage $Match.Groups[2].Value + Invoke-WebRequest -UseBasicParsing -TimeoutSec 120 ("https://nodejs.org/dist/" + $Match.Groups[3].Value + '/' + $Match.Groups[2].Value) -OutFile $Archive + if ((Get-FileHash $Archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Match.Groups[1].Value) { + throw 'The runtime checksum did not match. Run the installer again.' + } + Expand-Archive $Archive $Stage + Copy-Item (Join-Path $Stage ($Match.Groups[2].Value.Replace('.zip', '') + '\node.exe')) $Node + } + Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/relay.mjs" -OutFile (Join-Path $Stage 'relay.mjs') + Invoke-WebRequest -UseBasicParsing -TimeoutSec 30 "$Server/agent/setup.mjs" -OutFile (Join-Path $Stage 'setup.mjs') + & $Node (Join-Path $Stage 'setup.mjs') install $Server $Base + if ($LASTEXITCODE -ne 0) { throw 'Installation stopped. Correct the problem above, then run the installer again.' } + } finally { + Remove-Item -Recurse -Force $Stage + } +} + +Install-Agent diff --git a/dashboard/agent/install.sh b/dashboard/agent/install.sh new file mode 100644 index 0000000000000000000000000000000000000000..3e9b3d13cf804b24fca2ce45ce9150e1a61ed2e3 --- /dev/null +++ b/dashboard/agent/install.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu +umask 077 +server=__SERVER__ + +install_agent() { + [ "$(id -u)" != 0 ] || { echo 'Run this installer as your login user, without sudo.' >&2; return 1; } + case $(uname -s) in + Linux) os=linux; base=${XDG_DATA_HOME:-"$HOME/.local/share"}/agent-relay + command -v systemctl >/dev/null || { echo 'This installer needs a systemd user session.' >&2; return 1; } + systemctl --user show-environment >/dev/null || { echo 'Sign in to a systemd user session, then run the installer again.' >&2; return 1; } ;; + Darwin) os=darwin; base="$HOME/Library/Application Support/AgentRelay" ;; + *) echo "Use $server/agent/install.ps1 from Windows PowerShell." >&2; return 1 ;; + esac + case $(uname -m) in + x86_64|amd64) arch=x64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo 'This installer supports x64 and arm64 machines.' >&2; return 1 ;; + esac + command -v curl >/dev/null || { echo 'Install curl, then run this installer again.' >&2; return 1; } + mkdir -p "$base" + chmod 700 "$base" + stage=$(mktemp -d "$base/.install.XXXXXX") + trap 'rm -rf "$stage"' EXIT HUP INT TERM + if [ ! -x "$base/node" ] && [ -f /etc/NIXOS ]; then + echo 'Installing the agent runtime…' + nix --extra-experimental-features 'nix-command flakes' build nixpkgs#nodejs_24 --out-link "$base/node-runtime" + ln -sf "$base/node-runtime/bin/node" "$base/node" + elif [ ! -x "$base/node" ]; then + echo 'Downloading the agent runtime…' + curl -fsSL https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt -o "$stage/checksums" + archive=$(awk -v suffix="-$os-$arch.tar.gz" '$2 ~ /^node-v24\./ && substr($2, length($2)-length(suffix)+1) == suffix {print $2}' "$stage/checksums") + [ -n "$archive" ] || { echo 'No runtime download is available for this machine.' >&2; return 1; } + version=${archive#node-}; version=${version%%-$os-*} + curl -fsSL "https://nodejs.org/dist/$version/$archive" -o "$stage/$archive" + expected=$(awk -v file="$archive" '$2 == file {print $1}' "$stage/checksums") + if command -v sha256sum >/dev/null; then + actual=$(sha256sum "$stage/$archive"); actual=${actual%% *} + else + actual=$(shasum -a 256 "$stage/$archive"); actual=${actual%% *} + fi + [ "$actual" = "$expected" ] || { echo 'The runtime checksum did not match. Run the installer again.' >&2; return 1; } + tar -xzf "$stage/$archive" -C "$stage" + cp "$stage/${archive%.tar.gz}/bin/node" "$base/node" + chmod 700 "$base/node" + fi + curl -fsSL "$server/agent/relay.mjs" -o "$stage/relay.mjs" + curl -fsSL "$server/agent/setup.mjs" -o "$stage/setup.mjs" + "$base/node" "$stage/setup.mjs" install "$server" "$base" execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { stdio: 'inherit' }); +const psQuote = (text) => "'" + text.replaceAll("'", "''") + "'"; +const shellQuote = (text) => "'" + text.replaceAll("'", "'\\''") + "'"; + +async function stop() { + if (windows) ps(`$ErrorActionPreference = 'Stop' +if (Get-ScheduledTask -TaskName ${psQuote(task)} -ErrorAction SilentlyContinue) { Stop-ScheduledTask -TaskName ${psQuote(task)} } +Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { + $_.ExecutablePath -eq ${psQuote(join(base, 'node.exe'))} -and $_.CommandLine.Contains(${psQuote(join(base, 'relay.mjs'))}) +} | ForEach-Object { + & taskkill.exe /PID $_.ProcessId /T /F | Out-Null + 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.' } +}`); + else if (mac) { + if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status !== 0) return; + execFileSync('launchctl', ['bootout', `${domain}/net.paperclover.agent-relay`]); + for (let attempt = 0; attempt < 40; attempt++) { + try { execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }); } + catch { return; } + await sleep(250); + } + throw new Error('The previous agent is still stopping. Wait and run the installer again.'); + } + else if (spawnSync('systemctl', ['--user', 'show', '-P', 'LoadState', 'agent-relay.service'], { encoding: 'utf8' }).stdout.trim() === 'loaded') { + execFileSync('systemctl', ['--user', 'stop', 'agent-relay.service']); + } +} + +async function main() { + if (action === 'stop') { await stop(); return; } + if (action === 'start') { + if (windows) ps(`$ErrorActionPreference = 'Stop'; Start-ScheduledTask -TaskName ${psQuote(task)}`); + else if (mac) { + if (spawnSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }).status === 0) { + execFileSync('launchctl', ['kickstart', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' }); + } else execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' }); + } + else execFileSync('systemctl', ['--user', 'start', 'agent-relay.service'], { stdio: 'inherit' }); + return; + } + if (action === 'status') { + if (windows) ps(`$ErrorActionPreference = 'Stop'; Get-ScheduledTask -TaskName ${psQuote(task)} | Select-Object TaskName,State`); + else if (mac) execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'inherit' }); + else execFileSync('systemctl', ['--user', 'status', '--no-pager', 'agent-relay.service'], { stdio: 'inherit' }); + return; + } + if (action === 'uninstall') { + await stop(); + if (windows) ps(`$ErrorActionPreference = 'Stop'; Unregister-ScheduledTask -TaskName ${psQuote(task)} -Confirm:$false`); + else { + if (!mac) execFileSync('systemctl', ['--user', 'disable', 'agent-relay.service'], { stdio: 'inherit' }); + await rm(unit, { force: true }); + if (!mac) execFileSync('systemctl', ['--user', 'daemon-reload']); + } + console.log(`Startup removed. Pairing and files remain in ${base} and ${data}.`); + return; + } + if (action !== 'install' || !server || !installDir) throw new Error('Use install, status, start, stop, or uninstall.'); + const origin = new URL(server); + if (origin.origin !== server || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname)))) { + throw new Error('Use an HTTPS dashboard origin, or localhost for a preview.'); + } + const staged = dirname(process.argv[1]); + let previous; + try { previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + if (previous && previous.server !== server) throw new Error(`This machine is paired to ${previous.server}. Unlink it there before changing dashboards.`); + const input = createInterface({ input: process.stdin, output: process.stdout }); + const closed = new AbortController(); + input.once('close', () => closed.abort()); + const ask = (text) => input.question(text, { signal: closed.signal }); + let name, desktopWrite; + const roots = []; + try { + console.log(`Agent Relay · ${server}\nCodex and Claude Code must already be installed and signed in.`); + console.log('Linked clients can read saved Codex and Claude Code chats on this machine.'); + name = previous ? 'this machine' : (await ask(`Machine name [${hostname()}]: `)).trim() || hostname(); + if (previous) console.log('The existing machine pairing will be kept.'); + if (name.length > 100) throw new Error('Enter a machine name up to 100 characters.'); + console.log('Allowed folders apply to new chats. Existing chats keep their own permissions.'); + if (previous?.roots?.length) console.log(`Current folders: ${previous.roots.join(', ')}`); + console.log('Enter one project folder at a time. Leave blank to finish.'); + if (previous) console.log('Leave the first answer blank to keep the current folders. Enter - to clear them.'); + while (true) { + const answer = (await ask('Project folder: ')).trim(); + if (!answer) { if (!roots.length && previous) roots.push(...previous.roots); break; } + if (answer === '-' && !roots.length) break; + const path = await realpath(resolve(answer === '~' ? homedir() : answer.startsWith('~/') ? join(homedir(), answer.slice(2)) : answer)); + if (!(await stat(path)).isDirectory()) throw new Error('Choose an existing project folder.'); + if (!roots.includes(path)) roots.push(path); + } + if (!windows) { + console.log('Experimental Codex desktop control lets linked clients send messages and interrupt chats. App updates may break it.'); + const answer = (await ask(`Enable desktop control? [${previous?.desktopWrite ? 'Y/n' : 'y/N'}]: `)).trim().toLowerCase(); + if (answer && !['y', 'yes', 'n', 'no'].includes(answer)) throw new Error('Answer yes or no.'); + desktopWrite = answer ? ['y', 'yes'].includes(answer) : previous?.desktopWrite ?? false; + } else desktopWrite = false; + } catch (error) { + if (closed.signal.aborted) throw new Error('Keep the terminal open to answer the install prompts, then run the installer again.'); + throw error; + } finally { input.close(); } + await mkdir(data, { recursive: true, mode: 0o700 }); + if (previous) { + const response = await fetch(`${server}/pairing`, { headers: { Authorization: `Bearer ${previous.token}` }, signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error(`The saved pairing is unavailable (${response.status}). Check the dashboard before reinstalling.`); + } else { + await new Promise((accept, reject) => { + const child = spawn(process.execPath, [join(staged, 'relay.mjs'), 'pair', '--server', server, '--name', name, '--data-dir', data, + ...roots.flatMap((root) => ['--allow-root', root]), ...(desktopWrite ? ['--codex-desktop-write'] : [])], { stdio: 'inherit' }); + child.on('error', reject); + child.on('exit', (code) => code === 0 ? accept() : reject(new Error('Pairing stopped. Run the installer again for a new code.'))); + }); + previous = JSON.parse(await readFile(join(data, 'agent.json'), 'utf8')); + } + const config = { ...previous, roots, desktopWrite }; + const binaries = {}; + for (const cli of ['codex', 'claude']) { + try { + const found = windows ? execFileSync('where.exe', [cli], { encoding: 'utf8' }).trim().split(/\r?\n/)[0] : + execFileSync('/bin/sh', ['-c', 'command -v "$1"', 'sh', cli], { encoding: 'utf8' }).trim(); + if (found) binaries[cli] = found; + } catch { console.log(`${cli} was not found. Install it and rerun this installer to enable its chats.`); } + } + await stop(); + await writeFile(join(data, 'agent.json.pending'), JSON.stringify(config) + '\n', { mode: 0o600 }); + await rename(join(data, 'agent.json.pending'), join(data, 'agent.json')); + for (const file of ['relay.mjs', 'setup.mjs']) await copyFile(join(staged, file), join(base, file)); + const args = [join(base, 'relay.mjs'), 'run', '--data-dir', data, + ...Object.entries(binaries).flatMap(([cli, path]) => [`--${cli}-bin`, path])]; + const environment = Object.fromEntries(['PATH', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].flatMap((key) => process.env[key] ? [[key, process.env[key]]] : [])); + environment.PATH = [base, environment.PATH].filter(Boolean).join(delimiter); + if (windows) { + const runner = join(base, 'run.ps1'); + await writeFile(runner, "$ErrorActionPreference = 'Stop'\n" + Object.entries(environment).map(([key, value]) => `$env:${key} = ${psQuote(value)}`).join('\n') + + `\n& ${psQuote(process.execPath)} ${args.map(psQuote).join(' ')} *>> ${psQuote(join(base, 'agent.log'))}\nexit $LASTEXITCODE\n`); + ps(`$ErrorActionPreference = 'Stop' +$user = [Security.Principal.WindowsIdentity]::GetCurrent().Name +$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ${psQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${runner}"`)} +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user +$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries +Register-ScheduledTask -TaskName ${psQuote(task)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null +Start-ScheduledTask -TaskName ${psQuote(task)} +if ((Get-ScheduledTask -TaskName ${psQuote(task)}).State -eq 'Disabled') { throw 'Enable the Agent Relay task and run the installer again.' }`); + await writeFile(join(base, 'agent-relay.cmd'), `@echo off\r\n"${process.execPath}" "${join(base, 'setup.mjs')}" %*\r\n`); + } else { + await mkdir(dirname(unit), { recursive: true }); + if (mac) { + const xml = (text) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + await writeFile(unit, `\n\n +Labelnet.paperclover.agent-relay +ProgramArguments${[process.execPath, ...args].map((arg) => `${xml(arg)}`).join('')} +EnvironmentVariables${Object.entries(environment).map(([key, value]) => `${key}${xml(value)}`).join('')} +RunAtLoadKeepAliveThrottleInterval30 +StandardOutPath${xml(join(base, 'agent.log'))} +StandardErrorPath${xml(join(base, 'agent.log'))} +\n`); + execFileSync('launchctl', ['bootstrap', domain, unit], { stdio: 'inherit' }); + execFileSync('launchctl', ['print', `${domain}/net.paperclover.agent-relay`], { stdio: 'ignore' }); + } else { + const quote = (text) => '"' + text.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('%', '%%') + '"'; + await writeFile(unit, `[Unit]\nDescription=Agent Relay\n\n[Service]\nExecStart=${[process.execPath, ...args].map((arg) => quote(arg).replaceAll('$', '$$')).join(' ')}\n` + + Object.entries(environment).map(([key, value]) => `Environment=${quote(`${key}=${value}`)}`).join('\n') + + '\nRestart=on-failure\nRestartSec=30\nUMask=0077\n\n[Install]\nWantedBy=default.target\n'); + execFileSync('systemctl', ['--user', 'daemon-reload']); + execFileSync('systemctl', ['--user', 'enable', '--now', 'agent-relay.service'], { stdio: 'inherit' }); + execFileSync('systemctl', ['--user', 'is-active', '--quiet', 'agent-relay.service']); + } + await writeFile(join(base, 'agent-relay'), `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(join(base, 'setup.mjs'))} "$@"\n`, { mode: 0o700 }); + } + console.log(`Installed. Agent Relay starts at login.\nOpen ${server}/mcp/settings/agents and check that ${name} is online.`); + const manage = join(base, windows ? 'agent-relay.cmd' : 'agent-relay'); + console.log(`Check startup: ${windows ? '& ' + psQuote(manage) : shellQuote(manage)} status\nUse start, stop, or uninstall in place of status.`); +} + +main().catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/dashboard/agent/source.json b/dashboard/agent/source.json new file mode 100644 index 0000000000000000000000000000000000000000..29bfe76f9b9f83bc636e5743338a74fa964c8f16 --- /dev/null +++ b/dashboard/agent/source.json @@ -0,0 +1,4 @@ +{ + "source": "../../../agent-relay", + "entry": "src/agent.ts" +} diff --git a/dashboard/package.json b/dashboard/package.json index f68270bc15b2d0f0979450e128c60186b7d8540c..76b4f286bdf333124daa27c832a0862066f2e225 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -15,6 +15,7 @@ "@solidjs/router": "^1.0.0", "@types/node": "^26.6.2", "concurrently": "^10.0.5", + "esbuild": "0.28.2", "lucide-solid": "^1.48.0", "solid-js": "^1.9.15", "typescript": "^7.0.2", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 9f20c2d77993dc2e60ced953a345506606446a3e..4fb3cd5cb9f11e321709f526dbfdef9e12f23daf 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: concurrently: specifier: ^10.0.5 version: 10.0.5 + esbuild: + specifier: 0.28.2 + version: 0.28.2 lucide-solid: specifier: ^1.48.0 version: 1.48.0(solid-js@1.9.15) @@ -1379,7 +1382,6 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 - optional: true escalade@3.2.0: {} diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs new file mode 100644 index 0000000000000000000000000000000000000000..8aa7c2fe59ecf9200e7654aaf412080c83affcd1 --- /dev/null +++ b/dashboard/src/auth.rs @@ -0,0 +1,1114 @@ +use crate::*; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}, +}; +use rusqlite::{Connection, OptionalExtension, params as sql}; +use std::os::unix::fs::PermissionsExt; +use webauthn_rs::prelude::*; + +const COOKIE: &str = "__Host-snow-session"; +const FLOW_COOKIE: &str = "__Host-snow-flow"; +const SESSION_TTL: i64 = 30 * 86400; +const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm"]; + +pub struct Store { + pub db: Mutex, + pub origin: url::Url, + file: url::Url, + webauthn: Webauthn, + passwords: Semaphore, +} + +pub fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get("cookie")? + .to_str() + .ok()? + .split(';') + .find_map(|part| { + let (key, value) = part.trim().split_once('=')?; + (key == name).then(|| value.to_owned()) + }) +} +fn set_cookie(name: &str, value: &str, ttl: i64) -> String { + format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}") +} +fn row(db: &Connection, statement: &str, key: &str) -> Result { + let value: Option = db.query_row(statement, [key], |r| r.get(0)).optional()?; + Ok(value + .map(|s| serde_json::from_str(&s)) + .transpose()? + .unwrap_or(Value::Null)) +} +fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result { + let value: Option = db + .query_row( + "SELECT data FROM pending WHERE hash=? AND kind=? AND expires>?", + sql![mcp::hash(token), kind, now() as i64], + |r| r.get(0), + ) + .optional()?; + if consume && value.is_some() { + db.execute("DELETE FROM pending WHERE hash=?", [mcp::hash(token)])?; + } + Ok(value + .map(|s| serde_json::from_str(&s)) + .transpose()? + .unwrap_or(Value::Null)) +} +fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result { + db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?; + let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?; + if count >= 4096 { + return Err(Error::new( + 429, + "Too many sign-in requests. Try again in a few minutes.", + )); + } + let token = mcp::secret(); + db.execute( + "INSERT INTO pending VALUES (?,?,?,?)", + sql![ + mcp::hash(&token), + kind, + value.to_string(), + now() as i64 + ttl + ], + )?; + Ok(token) +} +pub fn user(db: &Connection, id: &str) -> Result { + let mut profile = row(db, "SELECT profile FROM users WHERE id=?", id)?; + if profile.is_null() { + return Err(Error::new( + 404, + "This account no longer exists. Sign in again.", + )); + } + profile["id"] = json!(id); + 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")?; + profile["groups"] = json!( + statement + .query_map([id], |r| Ok( + json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?}) + ))? + .collect::, _>>()? + ); + Ok(profile) +} +pub fn credentials(db: &Connection, id: &str) -> Result { + let mut statement = db.prepare( + "SELECT id, kind, label, created FROM credentials WHERE user_id=? ORDER BY created", + )?; + Ok(json!(statement.query_map([id], |r| Ok(json!({"id":r.get::<_,String>(0)?,"type":r.get::<_,String>(1)?,"userLabel":r.get::<_,Option>(2)?,"createdDate":r.get::<_,i64>(3)?})))?.collect::,_>>()?)) +} +pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> { + for key in [ + "id", + "groups", + "sessions", + "credentials", + "picture", + "console", + ] { + profile.as_object_mut().unwrap().remove(key); + } + db.execute( + "UPDATE users SET profile=? WHERE id=?", + sql![profile.to_string(), id], + ) + .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?; + Ok(()) +} +pub fn password_hash(password: &str) -> Result { + let salt = SaltString::encode_b64(&rand::random::<[u8; 16]>()) + .map_err(|_| Error::new(500, "Couldn't prepare password storage."))?; + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|_| Error::new(500, "Couldn't store the password."))? + .to_string()) +} +pub fn set_password(db: &Connection, id: &str, hash: &str) -> Result<()> { + db.execute( + "DELETE FROM credentials WHERE user_id=? AND kind='password'", + [id], + )?; + db.execute( + "INSERT INTO credentials VALUES (?,?,?,?,?,?)", + sql![ + uuid::Uuid::new_v4().to_string(), + id, + "password", + Option::::None, + (now() * 1000.0) as i64, + json!({"phc":hash}).to_string() + ], + )?; + Ok(()) +} + +impl Store { + pub fn new(data: &std::path::Path, origin: &str, file: &str, rp: &str) -> Result { + let origin = url::Url::parse(origin)?; + let file = url::Url::parse(file)?; + if origin.scheme() != "https" + || file.scheme() != "https" + || origin.path() != "/" + || file.path() != "/" + || origin.origin() == file.origin() + { + return Err(Error::new( + 500, + "Set separate HTTPS origins for Snowglobe and Files.", + )); + } + let rp_origin = url::Url::parse(&format!("https://{rp}"))?; + let webauthn = WebauthnBuilder::new(rp, &rp_origin)? + .append_allowed_origin(&origin) + .rp_name("snow globe") + .build()?; + std::fs::create_dir_all(data)?; + let path = data.canonicalize()?.join("accounts.sqlite"); + let db = Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE + | rusqlite::OpenFlags::SQLITE_OPEN_CREATE + | rusqlite::OpenFlags::SQLITE_OPEN_NOFOLLOW, + )?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; + 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); + 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; + CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE); + 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)); + 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); + 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); + CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; + Ok(Self { + db: Mutex::new(db), + origin, + file, + webauthn, + passwords: Semaphore::new(2), + }) + } + pub fn ready(&self) -> bool { + self.db + .lock() + .unwrap() + .query_row("SELECT EXISTS(SELECT 1 FROM users)", [], |r| r.get(0)) + .unwrap_or(false) + } + pub fn import(&self, export: Value) -> Result { + if self + .webauthn + .get_allowed_origins() + .first() + .and_then(|u| u.host_str()) + != export["rpId"].as_str() + { + return Err(Error::new( + 400, + "The export's passkey domain does not match this server.", + )); + } + let mut db = self.db.lock().unwrap(); + let digest = mcp::hash(&export.to_string()); + if db.query_row( + "SELECT EXISTS(SELECT 1 FROM migration WHERE digest=?)", + [&digest], + |r| r.get::<_, bool>(0), + )? { + return Ok( + json!({"accounts":array(&export["users"]).len(),"credentials":array(&export["users"]).iter().map(|u|array(&u["credentials"]).len()).sum::()}), + ); + } + if db.query_row("SELECT count(*) FROM users", [], |r| r.get::<_, i64>(0))? != 0 { + return Err(Error::new( + 409, + "Accounts already exist. Import into an empty store.", + )); + } + let transaction = db.transaction()?; + for role in array(&export["roles"]) { + if GROUPS.contains(&string(&role["name"])) { + transaction.execute( + "INSERT INTO roles VALUES (?,?)", + sql![string(&role["id"]), string(&role["name"])], + )?; + } + } + let mut count = 0; + for profile in array(&export["users"]) { + let id = string(&profile["id"]); + uuid::Uuid::parse_str(id)?; + string(&profile["username"]) + .parse::() + .map_err(|_| Error::new(400, "The source has an invalid username."))?; + let mut value = profile.clone(); + value["requiredActions"] = json!( + array(&profile["requiredActions"]) + .iter() + .filter(|v| **v == "UPDATE_PASSWORD" || **v == "UPDATE_PROFILE") + .collect::>() + ); + for key in ["id", "roles", "credentials"] { + value.as_object_mut().unwrap().remove(key); + } + transaction.execute( + "INSERT INTO users(id,profile) VALUES (?,?)", + sql![id, value.to_string()], + )?; + for role in array(&profile["roles"]) { + transaction.execute( + "INSERT INTO memberships SELECT ?,id FROM roles WHERE id=?", + sql![id, string(role)], + )?; + } + for credential in array(&profile["credentials"]) { + let kind = string(&credential["type"]); + let source = &credential["credentialData"]; + let data = match kind { + "password" => { + if source["algorithm"] != "argon2" + || source["additionalParameters"]["type"][0] != "id" + { + return Err(Error::new( + 500, + "The source uses an unsupported password format.", + )); + } + let parameters = &source["additionalParameters"]; + let salt = STANDARD_NO_PAD + .encode(STANDARD.decode(string(&credential["secretData"]["salt"]))?); + let hash = STANDARD_NO_PAD + .encode(STANDARD.decode(string(&credential["secretData"]["value"]))?); + let phc = format!( + "$argon2id$v=19$m={},t={},p={}${}${}", + string(¶meters["memory"][0]), + source["hashIterations"], + string(¶meters["parallelism"][0]), + salt, + hash + ); + PasswordHash::new(&phc).map_err(|_| { + Error::new(500, "The source password hash couldn't be imported.") + })?; + json!({"phc":phc}) + } + "webauthn-passwordless" => { + let key: serde_cbor_2::Value = serde_cbor_2::from_slice( + &URL_SAFE_NO_PAD.decode(string(&source["credentialPublicKey"]))?, + )?; + let public_key = COSEKey::try_from(&key)?; + let cred = Credential { + cred_id: STANDARD.decode(string(&source["credentialId"]))?.into(), + cred: public_key, + counter: source["counter"].as_u64().unwrap_or(0).try_into()?, + transports: serde_json::from_value(source["transports"].clone()) + .unwrap_or(None), + user_verified: true, + backup_eligible: false, + backup_state: false, + registration_policy: serde_json::from_value(json!("required"))?, + extensions: Default::default(), + attestation: Default::default(), + attestation_format: AttestationFormat::None, + }; + // Keycloak omits backup flags; learn them only from the first verified assertion. + json!({"passkey":Passkey::from(cred),"handle":URL_SAFE_NO_PAD.encode(id.as_bytes()),"backupUnknown":true}) + } + _ => { + return Err(Error::new( + 500, + "The source has a credential type this import doesn't support.", + )); + } + }; + transaction.execute( + "INSERT INTO credentials VALUES (?,?,?,?,?,?)", + sql![ + string(&credential["id"]), + id, + kind, + credential["userLabel"].as_str(), + credential["createdDate"].as_i64().unwrap_or(0), + data.to_string() + ], + )?; + count += 1; + } + } + transaction.execute("INSERT INTO migration VALUES (?)", [digest])?; + transaction.commit()?; + Ok(json!({"accounts":array(&export["users"]).len(),"credentials":count})) + } + pub fn session(&self, headers: &HeaderMap, client: &str) -> Result { + let Some(token) = cookie(headers, COOKIE) else { + return Ok(Value::Null); + }; + let db = self.db.lock().unwrap(); + let id: Option = db + .query_row( + "SELECT user_id FROM sessions WHERE hash=? AND client=? AND expires>?", + sql![mcp::hash(&token), client, now() as i64], + |r| r.get(0), + ) + .optional()?; + let Some(id) = id else { + return Ok(Value::Null); + }; + let user = user(&db, &id)?; + if user["enabled"] != true { + return Ok(Value::Null); + } + db.execute( + "UPDATE sessions SET last_used=? WHERE hash=? AND last_used, + ) -> Result { + let token = mcp::secret(); + let db = self.db.lock().unwrap(); + if user(&db, id)?["enabled"] != true { + return Err(Error::new(403, "This account is disabled.")); + } + if let Some(expected) = password { + if row( + &db, + "SELECT data FROM credentials WHERE user_id=? AND kind='password'", + id, + )? != *expected + { + return Err(Error::new(401, "Your password changed. Sign in again.")); + } + } + db.execute("DELETE FROM sessions WHERE expires<=?", [now() as i64])?; + let ip = headers + .get("X-Studio-Client-IP") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + db.execute( + "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)", + sql![ + mcp::hash(&token), + id, + client, + now() as i64 + SESSION_TTL, + ip, + (now() * 1000.0) as i64, + (now() * 1000.0) as i64, + now() as i64 + ], + )?; + Ok(set_cookie(COOKIE, &token, SESSION_TTL)) + } + fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> { + let ip = headers + .get("X-Studio-Client-IP") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + let db = self.db.lock().unwrap(); + db.execute("DELETE FROM attempts WHERE expires<=?", [now() as i64])?; + let address = mcp::hash(ip); + db.execute( + "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1", + sql![address, now() as i64 + 300], + )?; + let total: i64 = + db.query_row("SELECT count FROM attempts WHERE key=?", [address], |r| { + r.get(0) + })?; + if total > 100 { + return Err(Error::new( + 429, + "Too many attempts. Try again in five minutes.", + )); + } + let key = mcp::hash(&format!("{ip}:{name}")); + db.execute( + "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1", + sql![key, now() as i64 + 300], + )?; + let count: i64 = db.query_row("SELECT count FROM attempts WHERE key=?", [key], |r| { + r.get(0) + })?; + if count > 20 { + return Err(Error::new( + 429, + "Too many attempts. Try again in five minutes.", + )); + } + Ok(()) + } + fn csrf(&self, headers: &HeaderMap, body: &Value) -> Result<()> { + if headers.get("origin").and_then(|v| v.to_str().ok()) + != Some(self.origin.origin().ascii_serialization().as_str()) + { + return Err(Error::new(403, "Open sign-in on Snowglobe and try again.")); + } + let cookie = cookie(headers, FLOW_COOKIE).unwrap_or_default(); + if cookie.is_empty() + || !bool::from(cookie.as_bytes().ct_eq(string(&body["csrf"]).as_bytes())) + || pending(&self.db.lock().unwrap(), &cookie, "csrf", false)?.is_null() + { + return Err(Error::new( + 403, + "Sign-in expired. Reload the page and try again.", + )); + } + Ok(()) + } + fn next(&self, id: &str, flow: &str, path: &str) -> Result { + if flow.is_empty() { + if !path.starts_with('/') + || path.starts_with("//") + || path.contains('\\') + || path.chars().any(char::is_control) + { + return Ok("/".into()); + } + return Ok(path.to_owned()); + } + let db = self.db.lock().unwrap(); + if !array(&user(&db, id)?["requiredActions"]).is_empty() { + return Ok("/account".into()); + } + let value = pending(&db, flow, "file", false)?; + if value.is_null() { + return Err(Error::new( + 400, + "File sign-in expired. Open Files and try again.", + )); + } + let code = issue(&db, "handoff", json!({"user":id,"flow":flow}), 60)?; + Ok(format!( + "{}auth/file/callback?code={}", + self.file, + encoded(&code) + )) + } + pub fn sessions(db: &Connection, id: &str) -> Result { + let mut statement = db.prepare("SELECT hash,ip,created,last_used,client FROM sessions WHERE user_id=? AND expires>? ORDER BY last_used DESC")?; + 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::,_>>()?)) + } + pub async fn hash_password(&self, password: &str) -> Result { + let _slot = self + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let password = password.to_owned(); + tokio::task::spawn_blocking(move || password_hash(&password)).await? + } + pub fn recent(&self, headers: &HeaderMap) -> Result<()> { + let token = cookie(headers, COOKIE).unwrap_or_default(); + 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))?; + if !valid { + return Err(Error::new( + 403, + "Sign out and sign in again before changing sign-in methods.", + )); + } + Ok(()) + } + pub fn setup_link(&self, id: &str) -> Result { + let db = self.db.lock().unwrap(); + let profile = user(&db, id)?; + if profile["enabled"] != true { + return Err(Error::new( + 400, + "Enable this account before creating a setup link.", + )); + } + db.execute( + "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?", + [id], + )?; + let token = issue(&db, "setup", json!({"user":id}), 86400)?; + Ok(format!("{}sign-in?setup={}", self.origin, token)) + } +} + +pub async fn route(State(app): State>, request: Request) -> Result { + let auth = &app.auth; + let path = request.uri().path().to_owned(); + let method = request.method().clone(); + let query: HashMap = + url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes()) + .into_owned() + .collect(); + let headers = request.headers().clone(); + if path == "/auth/file/check" && method == Method::GET { + let user = auth.session(&headers, "file")?; + if user.is_null() || !array(&user["requiredActions"]).is_empty() { + return Ok(StatusCode::UNAUTHORIZED.into_response()); + } + let groups = array(&user["groups"]) + .iter() + .map(|g| string(&g["name"])) + .collect::>() + .join(","); + return Ok(( + StatusCode::NO_CONTENT, + [ + ( + "X-Auth-Request-Preferred-Username", + string(&user["username"]).to_owned(), + ), + ("X-Auth-Request-Groups", groups), + ], + ) + .into_response()); + } + if path == "/auth/file/sign-in" && method == Method::GET { + let target = query + .get("rd") + .map(String::as_str) + .unwrap_or(auth.file.as_str()); + let destination = auth.file.join(target)?; + if destination.origin() != auth.file.origin() + || !destination.username().is_empty() + || destination.password().is_some() + { + return Err(Error::new(400, "Open Files to sign in.")); + } + let flow = issue( + &auth.db.lock().unwrap(), + "file", + json!({"next":destination}), + 300, + )?; + return Ok(( + StatusCode::FOUND, + [ + ( + "location", + format!("{}auth/continue?flow={flow}", auth.origin), + ), + ("set-cookie", set_cookie(FLOW_COOKIE, &flow, 300)), + ], + ) + .into_response()); + } + if path == "/auth/continue" && method == Method::GET { + let flow = query.get("flow").cloned().unwrap_or_default(); + let user = auth.session(&headers, "dashboard")?; + let next = if user.is_null() { + format!("/sign-in?flow={}", encoded(&flow)) + } else { + auth.next(string(&user["id"]), &flow, "/")? + }; + return Ok((StatusCode::FOUND, [("location", next)]).into_response()); + } + if path == "/auth/file/callback" && method == Method::GET { + let token = query.get("code").cloned().unwrap_or_default(); + let (id, next) = { + let mut db = auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let code = pending(&transaction, &token, "handoff", false)?; + let flow = cookie(&headers, FLOW_COOKIE).unwrap_or_default(); + if code.is_null() + || flow.is_empty() + || !bool::from(flow.as_bytes().ct_eq(string(&code["flow"]).as_bytes())) + { + return Err(Error::new( + 403, + "File sign-in expired. Open Files and try again.", + )); + } + let target = pending(&transaction, &flow, "file", true)?; + if target.is_null() { + return Err(Error::new( + 403, + "File sign-in expired. Open Files and try again.", + )); + } + pending(&transaction, &token, "handoff", true)?; + let user = user(&transaction, string(&code["user"]))?; + if user["enabled"] != true { + return Err(Error::new(403, "This account is disabled. Contact Clover.")); + } + let result = ( + string(&code["user"]).to_owned(), + string(&target["next"]).to_owned(), + ); + transaction.commit()?; + result + }; + let session = auth.create_session(&id, "file", &headers, None)?; + return Ok(( + StatusCode::FOUND, + [("location", next), ("set-cookie", session)], + ) + .into_response()); + } + if path == "/auth/status" && method == Method::GET { + let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?; + let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?}); + if let Some(setup) = query.get("setup") { + let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?; + if entry.is_null() { + return Err(Error::new( + 410, + "This link expired. Ask Clover for a new one.", + )); + } + value["setup"] = + user(&auth.db.lock().unwrap(), string(&entry["user"]))?["username"].clone(); + } + return Ok(( + [ + ("set-cookie", set_cookie(FLOW_COOKIE, &csrf, 900)), + ("cache-control", "no-store".into()), + ], + axum::Json(value), + ) + .into_response()); + } + if path == "/auth/sign-out" || path == "/auth/file/sign-out" { + if method == Method::GET && path == "/auth/file/sign-out" { + return Ok(axum::response::Html("Sign out of Files
").into_response()); + } + if method != Method::POST { + return Err(Error::new(405, "Use the sign-out button.")); + } + let expected = if path.contains("/file/") { + &auth.file + } else { + &auth.origin + }; + if headers.get("origin").and_then(|v| v.to_str().ok()) + != Some(expected.origin().ascii_serialization().as_str()) + { + return Err(Error::new(403, "Open your account to sign out.")); + } + if let Some(token) = cookie(&headers, COOKIE) { + auth.db + .lock() + .unwrap() + .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?; + } + if path.contains("/file/") { + return Ok(( + StatusCode::SEE_OTHER, + [ + ("set-cookie", set_cookie(COOKIE, "", 0)), + ("location", "/".into()), + ], + ) + .into_response()); + } + return Ok(( + [("set-cookie", set_cookie(COOKIE, "", 0))], + axum::Json(json!({"next":"/sign-in"})), + ) + .into_response()); + } + if method != Method::POST { + return Err(Error::new(404, "No sign-in action here.")); + } + let body: Value = + serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 128 * 1024).await?) + .map_err(|_| Error::new(400, "Reload the form and try again."))?; + auth.csrf(&headers, &body)?; + if path == "/auth/password" || path == "/auth/passkey/start" { + let name = string(&body["username"]).trim().to_lowercase(); + if name.len() > 254 || name.is_empty() { + return Err(Error::new(400, "Enter your username.")); + } + auth.limit(&headers, &name)?; + let id: Option = 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()?; + let user = id + .as_ref() + .map(|id| user(&auth.db.lock().unwrap(), id)) + .transpose()? + .unwrap_or(Value::Null); + if path == "/auth/password" { + let password = string(&body["password"]).to_owned(); + if password.len() > 1024 { + return Err(Error::new(400, "That password is too long.")); + } + let data = row( + &auth.db.lock().unwrap(), + "SELECT data FROM credentials WHERE user_id=? AND kind='password'", + id.as_deref().unwrap_or(""), + )?; + let phc = string(&data["phc"]).to_owned(); + let _slot = auth + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let verified = tokio::task::spawn_blocking(move || { + if phc.is_empty() { + let _ = password_hash(&password); + return false; + } + PasswordHash::new(&phc).is_ok_and(|hash| { + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok() + }) + }) + .await?; + if !verified || user["enabled"] != true { + return Err(Error::new( + 401, + "That username or password doesn't match. Try again.", + )); + } + let id = id.unwrap(); + let session = auth.create_session(&id, "dashboard", &headers, Some(&data))?; + let next = if !array(&user["requiredActions"]).is_empty() { + "/account".into() + } else { + auth.next(&id, string(&body["flow"]), string(&body["next"]))? + }; + return Ok( + ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(), + ); + } + if user["enabled"] != true { + return Err(Error::new( + 401, + "No passkey is available for that username. Try your password.", + )); + } + let id = id.unwrap(); + let keys = passkeys(&auth.db.lock().unwrap(), &id)?; + if keys.is_empty() { + return Err(Error::new( + 401, + "No passkey is available for that username. Try your password.", + )); + } + let (options, state) = auth.webauthn.start_passkey_authentication(&keys)?; + let token = issue( + &auth.db.lock().unwrap(), + "authentication", + json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"]}), + 300, + )?; + return Ok(axum::Json(json!({"options":options,"token":token})).into_response()); + } + if path == "/auth/passkey/finish" { + let value = pending( + &auth.db.lock().unwrap(), + string(&body["token"]), + "authentication", + true, + )?; + if value.is_null() || value["csrf"] != body["csrf"] { + return Err(Error::new(403, "Passkey sign-in expired. Try again.")); + } + let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone()) + .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?; + let mut state = value["state"].clone(); + let mut allowed: Vec = + serde_json::from_value(state["ast"]["credentials"].clone())?; + { + let db = auth.db.lock().unwrap(); + let mut statement = db.prepare( + "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'", + )?; + for stored in + statement.query_map([string(&value["user"])], |r| r.get::<_, String>(0))? + { + let stored: Value = serde_json::from_str(&stored?)?; + let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?; + if stored["backupUnknown"] == true + && passkey.cred_id().as_slice() == credential.get_credential_id() + { + let flags = credential + .response + .authenticator_data + .as_slice() + .get(32) + .copied() + .ok_or_else(|| Error::new(400, "The passkey response was incomplete."))?; + for key in &mut allowed { + if key.cred_id == *passkey.cred_id() { + key.backup_eligible = flags & 8 != 0; + key.backup_state = flags & 16 != 0; + } + } + } + } + } + state["ast"]["credentials"] = json!(allowed); + let state: PasskeyAuthentication = serde_json::from_value(state)?; + let result = auth + .webauthn + .finish_passkey_authentication(&credential, &state) + .map_err(|error| { + eprintln!("passkey authentication: {error:?}"); + Error::new( + 401, + "That passkey couldn't sign in. Try again or use your password.", + ) + })?; + let id = string(&value["user"]); + { + let db = auth.db.lock().unwrap(); + let user = user(&db, id)?; + if user["enabled"] != true { + return Err(Error::new(403, "This account is disabled. Contact Clover.")); + } + let mut statement = db.prepare( + "SELECT id,data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'", + )?; + let rows = statement + .query_map([id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + let mut matched = false; + for (key, data) in rows { + let mut data: Value = serde_json::from_str(&data)?; + let mut passkey: Passkey = serde_json::from_value(data["passkey"].clone())?; + if passkey.cred_id() == result.cred_id() { + if let Some(handle) = body["credential"]["response"]["userHandle"].as_str() { + if !handle.is_empty() && handle != string(&data["handle"]) { + return Err(Error::new( + 401, + "That passkey belongs to a different account.", + )); + } + } + matched = true; + let current: Credential = passkey.clone().into(); + if (current.counter != 0 || result.counter() != 0) + && result.counter() <= current.counter + { + return Err(Error::new( + 401, + "This passkey returned an old counter. Try another sign-in method.", + )); + } + if data["backupUnknown"] == true { + let mut key: Credential = passkey.into(); + key.backup_eligible = result.backup_eligible(); + key.backup_state = result.backup_state(); + passkey = key.into(); + data.as_object_mut().unwrap().remove("backupUnknown"); + } + passkey.update_credential(&result); + data["passkey"] = json!(passkey); + db.execute( + "UPDATE credentials SET data=? WHERE id=?", + sql![data.to_string(), key], + )?; + break; + } + } + if !matched { + return Err(Error::new( + 401, + "This passkey was removed. Try another sign-in method.", + )); + } + } + let session = auth.create_session(id, "dashboard", &headers, None)?; + let next = + if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() { + "/account".into() + } else { + auth.next(id, string(&value["flow"]), string(&value["next"]))? + }; + return Ok(([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response()); + } + if path == "/auth/setup" { + let email = string(&body["email"]).trim(); + if !email.contains('@') || email.len() > 254 { + return Err(Error::new(400, "Enter your email address.")); + } + let password = string(&body["password"]).to_owned(); + if password.chars().count() < 8 || password.len() > 1024 { + return Err(Error::new( + 400, + "Use a password with at least 8 characters.", + )); + } + let _slot = auth + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let hash = tokio::task::spawn_blocking(move || password_hash(&password)).await??; + let id = { + let mut db = auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let entry = pending(&transaction, string(&body["setup"]), "setup", true)?; + if entry.is_null() { + return Err(Error::new( + 410, + "This link expired. Ask Clover for a new one.", + )); + } + let id = string(&entry["user"]).to_owned(); + let mut profile = user(&transaction, &id)?; + profile["email"] = json!(email); + profile["emailVerified"] = json!(false); + if profile["enabled"] != true { + return Err(Error::new( + 403, + "This account is disabled. Ask Clover for a new link.", + )); + } + profile["requiredActions"] = json!([]); + set_password(&transaction, &id, &hash)?; + save_user(&transaction, &id, profile)?; + transaction.execute("DELETE FROM sessions WHERE user_id=?", [&id])?; + transaction.commit()?; + id + }; + users::revoke_connections(&app, &id)?; + return Ok(( + [( + "set-cookie", + auth.create_session(&id, "dashboard", &headers, None)?, + )], + axum::Json(json!({"next":"/account?welcome=1"})), + ) + .into_response()); + } + let user = auth.session(&headers, "dashboard")?; + if user.is_null() { + return Err(Error::new(401, "Sign in to manage your account.")); + } + let id = string(&user["id"]); + if path == "/auth/passkey/register" { + auth.recent(&headers)?; + let db = auth.db.lock().unwrap(); + let keys = passkeys(&db, id)?; + let ids = keys.iter().map(|key| key.cred_id().clone()).collect(); + let uuid = uuid::Uuid::parse_str(id)?; + let (options, state) = auth.webauthn.start_passkey_registration( + uuid, + string(&user["username"]), + string(&user["username"]), + Some(ids), + )?; + let token = issue( + &db, + "registration", + json!({"user":id,"csrf":body["csrf"],"state":state}), + 300, + )?; + return Ok(axum::Json(json!({"options":options,"token":token})).into_response()); + } + if path == "/auth/passkey/save" { + auth.recent(&headers)?; + let db = auth.db.lock().unwrap(); + let value = pending(&db, string(&body["token"]), "registration", true)?; + if value.is_null() || value["user"] != user["id"] || value["csrf"] != body["csrf"] { + return Err(Error::new(403, "Passkey setup expired. Try again.")); + } + let credential: RegisterPublicKeyCredential = + serde_json::from_value(body["credential"].clone()).map_err(|_| { + Error::new(400, "The browser couldn't create your passkey. Try again.") + })?; + let state: PasskeyRegistration = serde_json::from_value(value["state"].clone())?; + let passkey = auth + .webauthn + .finish_passkey_registration(&credential, &state) + .map_err(|_| Error::new(400, "That passkey couldn't be added. Try again."))?; + let label = string(&body["label"]).trim(); + if label.len() > 100 { + return Err(Error::new(400, "Use a shorter passkey name.")); + } + 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()])?; + return Ok(StatusCode::NO_CONTENT.into_response()); + } + if path == "/auth/password/change" { + auth.recent(&headers)?; + let data = row( + &auth.db.lock().unwrap(), + "SELECT data FROM credentials WHERE user_id=? AND kind='password'", + id, + )?; + let phc = string(&data["phc"]).to_owned(); + let current = string(&body["current"]).to_owned(); + let password = string(&body["password"]).to_owned(); + if password.chars().count() < 8 || password.len() > 1024 || current.len() > 1024 { + return Err(Error::new( + 400, + "Use a password with at least 8 characters.", + )); + } + auth.limit(&headers, id)?; + let _slot = auth + .passwords + .try_acquire() + .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?; + let hash = tokio::task::spawn_blocking(move || { + if !phc.is_empty() + && !PasswordHash::new(&phc).is_ok_and(|hash| { + Argon2::default() + .verify_password(current.as_bytes(), &hash) + .is_ok() + }) + { + return Err(Error::new( + 401, + "Your current password doesn't match. Try again.", + )); + } + password_hash(&password) + }) + .await??; + { + let mut db = auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let mut profile = self::user(&transaction, id)?; + if profile["enabled"] != true { + return Err(Error::new(403, "This account is disabled.")); + } + set_password(&transaction, id, &hash)?; + profile["requiredActions"] = json!( + array(&user["requiredActions"]) + .iter() + .filter(|v| **v != "UPDATE_PASSWORD") + .collect::>() + ); + save_user(&transaction, id, profile)?; + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; + transaction.commit()?; + } + users::revoke_connections(&app, id)?; + return Ok(( + [( + "set-cookie", + auth.create_session(id, "dashboard", &headers, None)?, + )], + StatusCode::NO_CONTENT, + ) + .into_response()); + } + Err(Error::new(404, "No account action here.")) +} + +fn passkeys(db: &Connection, id: &str) -> Result> { + let mut statement = db + .prepare("SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'")?; + statement + .query_map([id], |r| r.get::<_, String>(0))? + .map(|data| { + let value: Value = serde_json::from_str(&data?)?; + Ok(serde_json::from_value(value["passkey"].clone())?) + }) + .collect() +} diff --git a/dashboard/src/cache.rs b/dashboard/src/cache.rs index 7fe428f7ae307c3d57b5b4e193b95e63e4d4570c..e00d135bff9b49b098b477556ea000ab9791e135 100644 --- a/dashboard/src/cache.rs +++ b/dashboard/src/cache.rs @@ -51,34 +51,6 @@ impl Cache { Ok(entry) } - pub async fn coalesce(&self, key: String, load: F) -> Result> - where - F: FnOnce() -> Fut + Send + 'static, - Fut: Future> + Send + 'static, - { - let started = Instant::now(); - let entry = self.entry(key)?; - let guard = entry.loading.clone().lock_owned().await; - { - let state = entry.state.lock().unwrap(); - if let Some((at, value)) = &state.value - && *at >= started - { - return Ok(value.clone()); - } - if let Some((at, error)) = &state.failure - && *at >= started - { - return Err(error.clone()); - } - } - tokio::spawn(async move { - let _guard = guard; - entry.store(load().await) - }) - .await? - } - pub fn invalidate(&self, key: &str) { self.0.lock().unwrap().remove(key); } @@ -180,51 +152,6 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; #[tokio::test] - async fn coalesced_identity_reads_do_not_reuse_completed_or_failed_results() { - let cache = Arc::new(Cache::default()); - let calls = Arc::new(AtomicUsize::new(0)); - let mut readers = Vec::new(); - for _ in 0..100 { - let (cache, calls) = (cache.clone(), calls.clone()); - readers.push(tokio::spawn(async move { - cache - .coalesce("identity:owner".into(), move || async move { - calls.fetch_add(1, Ordering::SeqCst); - tokio::time::sleep(Duration::from_millis(20)).await; - Ok(serde_json::json!({"enabled":true})) - }) - .await - .unwrap() - })); - } - for reader in readers { - assert_eq!(reader.await.unwrap().value["enabled"], true); - } - assert_eq!(calls.load(Ordering::SeqCst), 1); - let disabled = cache - .coalesce("identity:owner".into(), || async { - Ok(serde_json::json!({"enabled":false})) - }) - .await - .unwrap(); - assert_eq!(disabled.value["enabled"], false); - assert!( - cache - .coalesce("identity:owner".into(), || async { - Err(Error::new(502, "unavailable")) - }) - .await - .is_err() - ); - let recovered = cache - .coalesce("identity:owner".into(), || async { - Ok(serde_json::json!({"enabled":true})) - }) - .await - .unwrap(); - assert_eq!(recovered.value["enabled"], true); - } - #[tokio::test] async fn disconnecting_reader_does_not_cancel_shared_load() { let cache = Arc::new(Cache::default()); let started = Arc::new(tokio::sync::Notify::new()); diff --git a/dashboard/src/core.rs b/dashboard/src/core.rs index fd6afcd1017c2f7d5576f117561e1549da6f999f..8fb702b04594580152036d5f585bdf824cbf703f 100644 --- a/dashboard/src/core.rs +++ b/dashboard/src/core.rs @@ -839,7 +839,9 @@ mod tests { #[tokio::test] async fn launcher_excludes_directories_and_grouped_definitions_before_import() { let root = std::env::temp_dir().join(format!("studio-launcher-{}", uuid::Uuid::new_v4())); - tokio::fs::create_dir_all(root.join("config")).await.unwrap(); + tokio::fs::create_dir_all(root.join("config")) + .await + .unwrap(); for directory in ["retired", "personal"] { tokio::fs::create_dir_all(root.join("service").join(directory)) .await @@ -864,7 +866,10 @@ mod tests { assert!(module.contains("/personal/service.pkl")); assert!(module.contains("/personal/fixture.pkl")); assert_eq!( - module.lines().filter(|line| line.starts_with("import ")).count(), + module + .lines() + .filter(|line| line.starts_with("import ")) + .count(), 2, ); tokio::fs::remove_dir_all(root).await.unwrap(); diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index 2c77577101c368bd758dda58189b5d575b893cb0..89d4533cb2d7404e857866376e8de5433e2b9b2c 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -1,4 +1,5 @@ mod apps; +mod auth; mod cache; mod core; mod deploys; @@ -82,6 +83,7 @@ impl Document { } struct App { + auth: auth::Store, mcp: mcp::Store, relay: relay::Broker, shale: shale::Backend, @@ -388,7 +390,46 @@ async fn main() -> std::result::Result<(), Box> { "STUDIO_PUBLIC_ORIGIN", &format!("https://snowglobe.{}", env("STUDIO_DOMAIN", "studio.test")), ); + let auth = auth::Store::new( + &PathBuf::from(env("STUDIO_DATA_DIR", "data")), + &origin, + &env( + "STUDIO_FILE_ORIGIN", + &format!("https://file.{}", env("STUDIO_DOMAIN", "studio.test")), + ), + &env( + "STUDIO_AUTH_RP_ID", + &format!("auth.{}", env("STUDIO_DOMAIN", "studio.test")), + ), + ) + .map_err(|error| std::io::Error::other(error.message))?; + if let Some(path) = std::env::args().skip(1).next() { + if path != "--import-accounts" { + return Err(std::io::Error::other("Unknown dashboard argument.").into()); + } + let path = std::env::args() + .nth(2) + .ok_or_else(|| std::io::Error::other("Provide an account export path."))?; + let result = auth + .import(serde_json::from_slice(&std::fs::read(path)?)?) + .map_err(|error| std::io::Error::other(error.message))?; + println!("{result}"); + return Ok(()); + } + let import = PathBuf::from(env("STUDIO_DATA_DIR", "data")).join("accounts-import.json"); + if import.exists() { + auth.import(serde_json::from_slice(&std::fs::read(&import)?)?) + .map_err(|error| std::io::Error::other(error.message))?; + std::fs::remove_file(&import)?; + } + if env("STUDIO_AUTH_REQUIRED", "0") == "1" && !auth.ready() { + return Err(std::io::Error::other( + "Import accounts before starting native authentication.", + ) + .into()); + } let app = Arc::new(App { + auth, mcp: mcp::Store::new(&PathBuf::from(env("STUDIO_DATA_DIR", "data")), &origin) .map_err(|error| std::io::Error::other(error.message))?, relay: relay::Broker::default(), @@ -437,19 +478,21 @@ async fn main() -> std::result::Result<(), Box> { let dist = env("STUDIO_WEB_DIR", "dist"); let router = Router::new() .route("/api/{*path}", any(api)) + .route("/auth/{*path}", any(auth::route)) .route("/oauth/{*path}", any(mcp::oauth)) .route("/.well-known/{*path}", any(mcp::oauth)) .nest_service("/assets", ServeDir::new(format!("{dist}/assets"))) .with_state(app.clone()) .merge(observability::router(app.clone())) .merge(shale::router(app.clone())) - .merge(relay::router(app)) + .merge(relay::router(app.clone())) .fallback_service( ServeDir::new(&dist).fallback(ServeFile::new(format!("{dist}/index.html"))), ) .layer(axum::middleware::from_fn( move |mut request: Request, next: axum::middleware::Next| { let proof = proof.clone(); + let app = app.clone(); async move { if mcp::public(request.uri().path()) { request.headers_mut().remove("Studio-Proxy-Token"); @@ -467,13 +510,81 @@ async fn main() -> std::result::Result<(), Box> { } request.headers_mut().remove("Studio-Proxy-Token"); } - let asset = request.uri().path().starts_with("/assets/"); + let path = request.uri().path().to_owned(); + let asset = path.starts_with("/assets/"); + if app.auth.ready() + && !mcp::public(&path) + && !path.starts_with("/auth/") + && !asset + && path != "/sign-in" + { + request.headers_mut().remove("User-Name"); + request.headers_mut().remove("User-Groups"); + let account = match app.auth.session(request.headers(), "dashboard") { + Ok(account) => account, + Err(error) => return error.into_response(), + }; + if account.is_null() { + return if path.starts_with("/api/") { + Error::new(401, "Sign in to Snowglobe.").into_response() + } else { + ( + StatusCode::FOUND, + [( + "location", + format!( + "/sign-in?next={}", + encoded(&request.uri().to_string()) + ), + )], + ) + .into_response() + }; + } + if !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ) && request + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()) + != Some(app.auth.origin.origin().ascii_serialization().as_str()) + { + return Error::new(403, "Open Snowglobe and try again.") + .into_response(); + } + if !array(&account["requiredActions"]).is_empty() + && !path.starts_with("/api/account") + && path.starts_with("/api/") + && path != "/api/me" + { + return Error::new( + 403, + "Change your temporary password in your account first.", + ) + .into_response(); + } + let groups = array(&account["groups"]) + .iter() + .map(|v| string(&v["name"])) + .collect::>() + .join(","); + request + .headers_mut() + .insert("User-Name", string(&account["username"]).parse().unwrap()); + request + .headers_mut() + .insert("User-Groups", groups.parse().unwrap()); + } let document = !asset && !request.uri().path().starts_with("/api/"); if document { request.headers_mut().remove("if-modified-since"); request.headers_mut().remove("if-none-match"); } let mut response = next.run(request).await; + response.headers_mut().insert("referrer-policy", "no-referrer".parse().unwrap()); + response.headers_mut().insert("x-content-type-options", "nosniff".parse().unwrap()); + response.headers_mut().insert("x-frame-options", "DENY".parse().unwrap()); if asset && response.status().is_success() { response.headers_mut().insert( "cache-control", diff --git a/dashboard/src/mcp.rs b/dashboard/src/mcp.rs index 703cb7624271ff0e719f92ee8b57ef9ec9a3ac7a..d1345e7318e05b8e8b958165eb1cf566b2178671 100644 --- a/dashboard/src/mcp.rs +++ b/dashboard/src/mcp.rs @@ -445,7 +445,7 @@ impl Store { tx.commit()?; return Ok(( StatusCode::FOUND, - [("location", format!("/mcp?request={}", encoded(&pending)))], + [("location", format!("/connect/{}", encoded(&pending)))], ) .into_response()); } @@ -490,6 +490,16 @@ impl Store { #[derive(Clone)] pub(crate) struct Grant(pub Value); +pub(crate) fn active_owner(app: &App, grant: &Value) -> Result { + let profile = match auth::user(&app.auth.db.lock().unwrap(), string(&grant["user"])) { + Ok(profile) => profile, + Err(error) if error.status == 404 => return Ok(false), + Err(error) => return Err(error), + }; + Ok(profile["enabled"] == true + && (grant["resource"] != app.mcp.resource("observability") + || array(&profile["groups"]).iter().any(|role| role["name"] == "infra-admin"))) +} pub fn router( app: Arc, catalog: &str, @@ -529,7 +539,9 @@ pub fn router( return Error::new(403, "This origin cannot use the connector.") .into_response(); } - match app.mcp.authenticate(request.headers(), &resource) { + match app.mcp.authenticate(request.headers(), &resource).and_then(|grant| { + if active_owner(&app, &grant)? { Ok(grant) } else { Err(Error::new(401, "invalid_token")) } + }) { Ok(grant) => { request.extensions_mut().insert(Grant(grant)); if let Some(value) = request.headers_mut().get_mut("authorization") { @@ -554,10 +566,16 @@ pub fn router( pub fn public(path: &str) -> bool { path.starts_with("/oauth/") - || path.starts_with("/mcp/") + || CATALOGS.iter().any(|(id, _, _)| { + path == format!("/mcp/{id}") || path.starts_with(&format!("/mcp/{id}/")) + }) || path.starts_with("/.well-known/oauth-") || path == "/pairing" || path == "/agent/connect" + || matches!( + path, + "/agent/install.sh" | "/agent/install.ps1" | "/agent/setup.mjs" | "/agent/relay.mjs" + ) || path.starts_with("/api/v1/") } pub async fn oauth(State(app): State>, request: Request) -> Response { @@ -590,19 +608,7 @@ pub async fn oauth(State(app): State>, request: Request) -> Response { let body = if registration {serde_json::from_slice(&bytes).map_err(|_| fail("invalid_client_metadata"))?} else {Value::Null}; if path == "/oauth/token" && method == Method::POST { let (_, grant) = app.mcp.exchange(&app.mcp.db.lock().unwrap(), &input, &headers)?; - let id = string(&grant["user"]); - let (profile, roles) = match tokio::try_join!( - host::call(json!({"operation":"iam.request","path":format!("/users/{id}"),"method":"GET","body":null})), - host::call(json!({"operation":"iam.request","path":format!("/users/{id}/role-mappings/realm"),"method":"GET","body":null})) - ) { - Ok(identity) => identity, - Err(error) if error.status == 404 => { - revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; - return Err(fail("invalid_grant")); - } - Err(error) => return Err(error), - }; - if profile["body"]["enabled"] != true || grant["resource"] == app.mcp.resource("observability") && !array(&roles["body"]).iter().any(|role| role["name"] == "infra-admin") { + if !active_owner(&app, &grant)? { revoke(&app.mcp.db.lock().unwrap(), string(&grant["id"]))?; return Err(fail("invalid_grant")); } @@ -633,6 +639,25 @@ pub async fn oauth(State(app): State>, request: Request) -> Response { response } +fn chosen_resources(body: &Value, resources: &[Value], shale: bool) -> Result { + if shale && body["resources"] == "all" { + return Ok(json!("all")); + } + let chosen = body["resources"] + .as_array() + .filter(|items| !items.is_empty() && items.len() <= resources.len()) + .ok_or_else(|| Error::new(400, "Choose each available resource once."))?; + if chosen.iter().enumerate().any(|(index, item)| { + !resources.iter().any(|resource| item == &resource["id"]) || chosen[..index].contains(item) + }) { + return Err(Error::new( + 403, + "Choose resources available to your account.", + )); + } + Ok(json!(chosen)) +} + pub async fn manage( app: Arc, method: &Method, @@ -672,32 +697,66 @@ pub async fn manage( .keep_alive(axum::response::sse::KeepAlive::default()) .into_response()); } - let consent_resource = if let ["consent", id] = parts { - get( + let consent_resource = if let ["connections", id] = parts + && method != Method::DELETE + { + let grant = get(&app.mcp.db.lock().unwrap(), &format!("grant:{id}"))?; + if grant["user"] != owner_id { + return Err(Error::new(404, "No connection with that ID.")); + } + string(&grant["resource"]).to_owned() + } else if let ["consent", id] = parts { + let pending = get( &app.mcp.db.lock().unwrap(), &format!("pending:{}", hash(id)), - )?["resource"] - .as_str() - .unwrap_or_default() - .to_owned() + )?; + if pending.is_null() { + return Err(Error::new( + 404, + "This connection request expired. Start it again.", + )); + } + if !pending["owner"].is_null() && pending["owner"] != owner_id { + return Err(Error::new( + 403, + "This connection request belongs to another account.", + )); + } + string(&pending["resource"]).to_owned() } else { String::new() }; let agent_consent = consent_resource == app.mcp.resource("agents"); let shale_consent = consent_resource == app.mcp.resource("shale"); + let catalog = CATALOGS + .iter() + .find(|(id, _, _)| consent_resource == app.mcp.resource(id)) + .map(|(id, _, _)| *id); let mut linked = true; - let resources: Vec = if agent_consent { + let mut resource_error = None; + let resources: Vec = if body["deny"] == true { + Vec::new() + } else if agent_consent { relay::machines(&app.mcp.db.lock().unwrap(), owner_id)? .into_iter() .map(|m| json!({"id":m["id"],"name":m["name"]})) .collect() - } else if shale_consent && body["deny"] != true { - match shale::repositories(&app, owner_id).await { + } else if shale_consent { + let available = if body["resources"] == "all" { + shale::verified_session(&app, owner_id).await.map(|_| Vec::new()) + } else { + shale::repositories(&app, owner_id).await + }; + match available { Ok(repositories) => repositories, Err(error) if error.status == 401 => { linked = false; Vec::new() } + Err(error) if method == Method::GET => { + resource_error = Some(error.message); + Vec::new() + } Err(error) => return Err(error), } } else if consent_resource == app.mcp.resource("observability") @@ -726,13 +785,14 @@ pub async fn manage( let machines = relay::machines(&tx, owner_id)?; let connections = grants.iter().map(|grant| { let name = if grant["client"].is_null() {grant["name"].clone()} else {get(&tx, &format!("client:{}", string(&grant["client"])))?["client_name"].clone()}; - 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(); - Ok(json!({"id":grant["id"],"name":name,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]})) + 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::>())}; + let catalog = CATALOGS.iter().find(|(id, _, _)| grant["resource"] == app.mcp.resource(id)).map(|(id, _, _)| *id); + Ok(json!({"id":grant["id"],"name":name,"catalog":catalog,"resources":resources,"scopes":grant["scopes"],"createdAt":grant["createdAt"]})) }).collect::>>()?; let shale = get(&tx, &format!("shale-session:{owner_id}"))?; let catalogs: Vec<_> = CATALOGS .iter() - .map(|(id, name, _)| json!({"name":name,"endpoint":app.mcp.resource(id)})) + .map(|(id, name, _)| json!({"id":id,"name":name,"endpoint":app.mcp.resource(id)})) .collect(); 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"]})}}) } @@ -770,29 +830,19 @@ pub async fn manage( "UPDATE records SET value=? WHERE key=?", rusqlite::params![pending.to_string(), key], )?; - json!({"client":get(&tx,&format!("client:{}",string(&pending["client"])))?["client_name"],"scopes":pending["scopes"],"resources":resources,"linked":linked}) + 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}) } else { if shale_consent - && get(&tx, &format!("shale-session:{owner_id}"))?["origin"] - != app.shale.origin.as_str() + && (!linked + || get(&tx, &format!("shale-session:{owner_id}"))?["origin"] + != app.shale.origin.as_str()) { return Err(Error::new( 401, "Link your Shale account before allowing repository access.", )); } - let chosen = body["resources"] - .as_array() - .filter(|a| !a.is_empty() && a.len() <= resources.len()) - .ok_or_else(|| Error::new(400, "Choose each available resource once."))?; - if chosen.iter().enumerate().any(|(index, r)| { - !resources.iter().any(|id| r == &id["id"]) || chosen[..index].contains(r) - }) { - return Err(Error::new( - 403, - "Choose resources available to your account.", - )); - } + let chosen = chosen_resources(&body, &resources, shale_consent)?; if list(&tx, "grant:")? .iter() .filter(|g| g["user"] == owner_id) @@ -826,13 +876,37 @@ pub async fn manage( } } ["relay", rest @ ..] => relay::manage(&app, &tx, rest, method, owner_id, &body)?, - ["connections", id] if method == Method::DELETE => { - let grant = get(&tx, &format!("grant:{id}"))?; + ["connections", id] + if method == Method::GET || method == Method::POST || method == Method::DELETE => + { + let key = format!("grant:{id}"); + let mut grant = get(&tx, &key)?; if grant["user"] != owner_id { return Err(Error::new(404, "No connection with that ID.")); } - revoke(&tx, id)?; - Value::Null + if method == Method::DELETE { + revoke(&tx, id)?; + Value::Null + } else if method == Method::GET { + json!({"resources": resources, "selected": grant["resources"], "linked": linked,"resourceError":resource_error}) + } else { + if shale_consent && !linked { + return Err(Error::new( + 401, + "Link your Shale account before allowing repository access.", + )); + } + let chosen = chosen_resources(&body, &resources, shale_consent)?; + grant["resources"] = json!(chosen); + if agent_consent { + grant["targets"] = json!(chosen); + } + tx.execute( + "UPDATE records SET value=? WHERE key=?", + rusqlite::params![grant.to_string(), key], + )?; + Value::Null + } } _ => return Err(Error::new(404, "No endpoint here.")), }; @@ -849,6 +923,28 @@ pub async fn manage( #[cfg(test)] mod tests { + #[test] + fn resource_updates_reject_empty_duplicates_and_foreign_choices() { + let resources = vec![json!({"id":"alpha"}), json!({"id":"beta"})]; + assert_eq!( + chosen_resources(&json!({"resources":["beta"]}), &resources, false).unwrap(), + json!(["beta"]) + ); + for selected in [ + json!([]), + json!(["alpha", "alpha"]), + json!(["foreign"]), + json!([null]), + ] { + assert!(chosen_resources(&json!({"resources":selected}), &resources, false).is_err()); + } + assert_eq!( + chosen_resources(&json!({"resources":"all"}), &[], true).unwrap(), + json!("all") + ); + assert!(chosen_resources(&json!({"resources":"all"}), &resources, false).is_err()); + } + use super::*; struct Fixture { store: Arc, diff --git a/dashboard/src/relay.rs b/dashboard/src/relay.rs index 8c02fbef820cce11a20ad4a7b1c5ce57597a57ea..e7e7af00789b6b65bb151dfa11ea84bae0b896d7 100644 --- a/dashboard/src/relay.rs +++ b/dashboard/src/relay.rs @@ -618,6 +618,9 @@ async fn rest(State(app): State>, request: Request) -> Response { let grant = app .mcp .authenticate(request.headers(), &app.mcp.resource("agents"))?; + if !mcp::active_owner(&app, &grant)? { + return Err(Error::new(401, "This connection's account is no longer authorized.")); + } let id = string(&grant["id"]); let method = request.method().clone(); let path = request @@ -778,13 +781,37 @@ impl ServerHandler for Agents { .into()) } } +async fn installer(State(app): State>, request: Request) -> Response { + let origin = app.mcp.origin.origin().ascii_serialization(); + let source = if request.uri().path().ends_with(".ps1") { + include_str!("../agent/install.ps1") + .replace("__SERVER__", &format!("'{}'", origin.replace('\'', "''"))) + } else { + include_str!("../agent/install.sh").replace( + "__SERVER__", + &format!("'{}'", origin.replace('\'', "'\\''")), + ) + }; + ([("content-type", "text/plain; charset=utf-8")], source).into_response() +} pub fn router(app: Arc) -> Router { let state = app.clone(); let expected_host = app.mcp.origin[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); + let agent = env("STUDIO_AGENT_DIR", "agent"); Router::new() .route("/pairing", any(pairing)) .route("/agent/connect", any(connect)) + .route("/agent/install.sh", axum::routing::get(installer)) + .route("/agent/install.ps1", axum::routing::get(installer)) + .route_service( + "/agent/setup.mjs", + ServeFile::new(format!("{agent}/setup.mjs")), + ) + .route_service( + "/agent/relay.mjs", + ServeFile::new(format!("{agent}/relay.mjs")), + ) .route("/api/v1/{*path}", any(rest)) .with_state(app.clone()) .merge(mcp::router(app, "agents", move || { diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs index b6d97956eed2e4bbbb4e4c5de556b15ef254bec8..5e114f75b0f01fbe9ee5c47f5bc01a79e235d907 100644 --- a/dashboard/src/shale.rs +++ b/dashboard/src/shale.rs @@ -149,10 +149,12 @@ fn text(element: scraper::ElementRef<'_>) -> String { fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Result { if repository.is_empty() || repository.len() > 255 - || matches!(repository, "." | ".." | "-") + || repository + .split('/') + .any(|part| matches!(part, "" | "." | ".." | "-")) || repository .chars() - .any(|c| c.is_control() || c.is_whitespace() || "/\\%?#".contains(c)) + .any(|c| c.is_control() || c.is_whitespace() || "\\%?#".contains(c)) { return Err(Error::new( 400, @@ -164,7 +166,7 @@ fn repository_path(origin: &url::Url, repository: &str, suffix: &[&str]) -> Resu .path_segments_mut() .unwrap() .clear() - .push(repository) + .extend(repository.split('/')) .extend(suffix.iter().copied()); Ok(target) } @@ -184,13 +186,17 @@ fn session(app: &App, owner: &str) -> Result { .map(str::to_owned) .ok_or_else(|| Error::new(401, "Link your Shale account from the dashboard's MCP tab.")) } -pub async fn repositories(app: &App, owner: &str) -> Result> { +pub(crate) async fn verified_session(app: &App, owner: &str) -> Result { let session = session(app, owner)?; username( &app.shale .page(&app.shale.origin.join("/-/settings")?, &session) .await?, )?; + Ok(session) +} +pub async fn repositories(app: &App, owner: &str) -> Result> { + let session = verified_session(app, owner).await?; let body = app.shale.page(&app.shale.origin, &session).await?; let document = document(&body, "page-index", None)?; let mut repositories = Vec::new(); @@ -359,12 +365,12 @@ impl ServerHandler for Shale { current(app, grant, &credential)?; if name == "list_repositories" { let mut repositories = repositories(app, string(&grant["user"])).await?; - repositories.retain(|r| array(&grant["resources"]).contains(&r["id"])); + repositories.retain(|r| grant["resources"] == "all" || array(&grant["resources"]).contains(&r["id"])); current(app, grant, &credential)?; return Ok(json!({"repositories":repositories})); } let repository = arguments.get("repository").and_then(Value::as_str) - .filter(|r| array(&grant["resources"]).iter().any(|id| id == *r)) + .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r)) .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?; let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { @@ -556,6 +562,15 @@ pub async fn manage( let owner_id = string(&owner["id"]); let key = format!("shale-session:{owner_id}"); match *method { + Method::GET => match repositories(&app, owner_id).await { + Ok(resources) => { + Ok(axum::Json(json!({"linked":true,"resources":resources})).into_response()) + } + Err(error) if error.status == 401 => { + Ok(axum::Json(json!({"linked":false,"resources":[]})).into_response()) + } + Err(error) => Err(error), + }, Method::POST => { let pending = if let Some(id) = body["request"].as_str() { let db = app.mcp.db.lock().unwrap(); @@ -740,10 +755,10 @@ pub async fn oauth(app: Arc, request: Request) -> Response { let _ = app.shale.get("/-/logout", Some(&session)).await; return Err(error); } - let mut target = app.mcp.origin.join("mcp")?; - if let Some(request) = link["request"].as_str() { - target.query_pairs_mut().append_pair("request", request); - } + let target = app.mcp.origin.join(&match link["request"].as_str() { + Some(request) => format!("connect/{request}"), + None => "mcp/settings/shale".to_owned(), + })?; Ok((StatusCode::SEE_OTHER, [("location", target.to_string())]).into_response()) }.await; let mut response = match result { @@ -779,7 +794,11 @@ mod tests { "..", "-", "../other", - "one/two", + "one//two", + "one/../two", + "one/./two", + "one/-/two", + "one/", "one\\two", "%2e%2e", "one?x", @@ -795,6 +814,17 @@ mod tests { let path = repository_path(&origin, "雪☃", &["issues", "1"]).unwrap(); assert_eq!(path.origin(), origin.origin()); assert_eq!(path.path(), "/%E9%9B%AA%E2%98%83/issues/1"); + let path = repository_path( + &origin, + "userscripts/discord-pluralkit-predict", + &["issues", "1"], + ) + .unwrap(); + assert_eq!(path.origin(), origin.origin()); + assert_eq!( + path.path(), + "/userscripts/discord-pluralkit-predict/issues/1" + ); } #[test] fn issue_pages_must_match_repository_identity_and_issue_number() { diff --git a/dashboard/src/telemetry.rs b/dashboard/src/telemetry.rs index 36977e4eb79125bb58d7b3efdd62a0ceafdf4b1c..2bf2d94de43d0a72bafc78fa2dec6995e12f9939 100644 --- a/dashboard/src/telemetry.rs +++ b/dashboard/src/telemetry.rs @@ -823,12 +823,20 @@ pub fn start(app: Arc) { .unwrap() .iter() .flat_map(|(id, job)| { - array(&job["job"]["TaskGroups"]).iter() + array(&job["job"]["TaskGroups"]) + .iter() .flat_map(|group| array(&group["Services"])) .flat_map(move |service| { array(&service["Tags"]).iter().filter_map(move |tag| { - string(tag).strip_prefix("studio-metrics-path=") - .map(|path| (id.clone(), string(&service["Name"]).to_owned(), path.to_owned())) + string(tag).strip_prefix("studio-metrics-path=").map( + |path| { + ( + id.clone(), + string(&service["Name"]).to_owned(), + path.to_owned(), + ) + }, + ) }) }) }) @@ -842,7 +850,10 @@ pub fn start(app: Arc) { let response = app .request( Method::GET, - &format!("{}{path}", endpoint(app.clone(), &service).await?), + &format!( + "{}{path}", + endpoint(app.clone(), &service).await? + ), )? .timeout(Duration::from_secs(5)) .send() @@ -852,7 +863,10 @@ pub fn start(app: Arc) { Method::POST, &format!( "{base}/api/v1/import/prometheus?{}", - params(&[("extra_label", format!("service={id}")), ("extra_label", format!("instance={service}"))]) + params(&[ + ("extra_label", format!("service={id}")), + ("extra_label", format!("instance={service}")) + ]) ), )? .body(response.text().await?) diff --git a/dashboard/src/users.rs b/dashboard/src/users.rs index 742e326ae641043555b2ab4928fa7d140201683b..1f356017ccbb204b1b6a1e63b65f33ed15c7872c 100644 --- a/dashboard/src/users.rs +++ b/dashboard/src/users.rs @@ -1,85 +1,7 @@ use crate::*; use axum::extract::{FromRequest, Multipart}; -use futures::{StreamExt, stream}; +use rusqlite::{OptionalExtension, params as sql}; -async fn call(path: &str, method: Method, body: Option) -> Result { - host::call(json!({"operation":"iam.request", "path":path, - "method":method.as_str(), "body":body})) - .await -} -async fn get(path: &str) -> Result { - Ok(call(path, Method::GET, None).await?["body"].take()) -} -async fn list() -> Result { - let list = get("/users?max=1000").await?; - let found = stream::iter(array(&list).iter().cloned()) - .map(|mut user| async move { - user["groups"] = get(&format!( - "/users/{}/role-mappings/realm", - encoded(string(&user["id"])) - )) - .await?; - for key in ["email", "firstName", "lastName"] { - if user.get(key).is_none() { - user[key] = Value::Null; - } - } - Ok::<_, Error>(user) - }) - .buffered(4) - .collect::>() - .await - .into_iter() - .collect::>>()?; - Ok(json!(found)) -} -async fn directory(app: Arc) -> Result> { - app.cache - .get( - "users".into(), - Duration::from_secs(300), - move || async move { - let (list, groups) = tokio::try_join!(list(), get("/roles"))?; - let users = stream::iter(array(&list).iter().cloned()) - .map(|mut user| async move { - user["sessions"] = - get(&format!("/users/{}/sessions", encoded(string(&user["id"])))) - .await?; - Ok::<_, Error>(user) - }) - .buffered(4) - .collect::>() - .await - .into_iter() - .collect::>>()?; - Ok(json!({"users":users,"groups":groups})) - }, - ) - .await -} -async fn found(id: &str) -> Result { - array(&list().await?) - .iter() - .find(|u| u["id"] == id) - .cloned() - .ok_or_else(|| Error::new(404, "No user with that id")) -} -async fn spare(me: &Value, id: &str, remove_role: Option<&str>) -> Result<()> { - let user = found(id).await?; - if user["username"] == me["name"] { - let keeps_admin = remove_role.is_some() - && array(&user["groups"]) - .iter() - .any(|g| g["name"] == "infra-admin" && g["name"] != remove_role.unwrap()); - if !keeps_admin { - return Err(Error::new( - 400, - "That would lock you out of this page. Sign in as another admin to change it.", - )); - } - } - Ok(()) -} fn uuid(id: &str) -> Result<()> { if regex::Regex::new( 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 { let value = match key { "username" => { let v = string(value).trim().to_lowercase(); - if !username_pattern.is_match(&v) { + if v.len() > 254 || !username_pattern.is_match(&v) { return Err(Error::new( 400, "Usernames use lowercase letters, digits, dots, dashes, and @", @@ -126,6 +48,9 @@ fn profile(body: &Value, full: bool) -> Result { .as_str() .ok_or_else(|| Error::new(400, "Enter a name or email address."))? .trim(); + if v.len() > 254 { + return Err(Error::new(400, "Use a shorter name or email address.")); + } if key == "email" && !v.is_empty() && (!v.contains('@') || v.contains(' ')) { return Err(Error::new(400, "Enter a full email address")); } @@ -138,7 +63,7 @@ fn profile(body: &Value, full: bool) -> Result { value.clone() } _ => { - if !value.is_array() || array(value).iter().any(|v| !v.is_string()) { + if !value.is_array() || array(value).iter().any(|v| v != "UPDATE_PASSWORD") { return Err(Error::new(400, "Invalid required actions.")); } value.clone() @@ -151,10 +76,41 @@ fn profile(body: &Value, full: bool) -> Result { fn password(value: &Value) -> Result<&str> { value .as_str() - .filter(|s| s.chars().count() >= 8) + .filter(|s| s.chars().count() >= 8 && s.len() <= 1024) .ok_or_else(|| Error::new(400, "Use at least 8 characters")) } +pub(crate) fn revoke_connections(app: &App, id: &str) -> Result<()> { + let mut db = app.mcp.db.lock().unwrap(); + let transaction = db.transaction()?; + for grant in mcp::list(&transaction, "grant:")? { + if grant["user"] == id { + mcp::revoke(&transaction, string(&grant["id"]))?; + } + } + transaction.execute( + "DELETE FROM records WHERE json_extract(value,'$.owner')=?", + [id], + )?; + transaction.commit()?; + Ok(()) +} + +pub async fn self_user(app: &App, me: &Value) -> Result { + let db = app.auth.db.lock().unwrap(); + let id: Option = db + .query_row( + "SELECT id FROM users WHERE username=?", + [string(&me["name"])], + |r| r.get(0), + ) + .optional()?; + auth::user( + &db, + &id.ok_or_else(|| Error::new(401, "Sign in again to open your account."))?, + ) +} + pub async fn route( app: Arc, method: &Method, @@ -163,142 +119,207 @@ pub async fn route( body: Value, ) -> Result { if parts.is_empty() && method == Method::GET { - return Ok(directory(app).await?.response()); + let db = app.auth.db.lock().unwrap(); + let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?; + let ids = statement + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + let users = ids + .iter() + .map(|id| { + let mut user = auth::user(&db, id)?; + user["sessions"] = auth::Store::sessions(&db, id)?; + Ok(user) + }) + .collect::>>()?; + let mut statement = db.prepare("SELECT id,name FROM roles ORDER BY name")?; + let groups = statement + .query_map([], |r| { + Ok(json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?})) + })? + .collect::, _>>()?; + return Ok(Document::new(json!({"users":users,"groups":groups})).response()); } - if let Some(id) = parts.first() { - uuid(id)?; - } - let value = match parts { - [] if method == Method::POST => { - let mut profile = profile(&body["profile"], true)?; - let setup = string(&body["setup"]["kind"]); - if setup == "email" && profile["email"].is_null() { - return Err(Error::new(400, "Add an email address to send a setup link")); - } - if setup != "email" && setup != "password" { - return Err(Error::new(400, "Choose how this user signs in.")); - } - if setup == "password" { - password(&body["setup"]["password"])?; - } - if !body["groups"].is_array() { - return Err(Error::new(400, "Choose groups.")); - } - for group in array(&body["groups"]) { - uuid(string(group))?; - } - let actions = if setup == "email" { - json!(["UPDATE_PASSWORD", "VERIFY_EMAIL"]) - } else { - json!([]) - }; - profile["enabled"] = json!(true); - profile["emailVerified"] = json!(false); - profile["requiredActions"] = actions.clone(); - let response = call("/users", Method::POST, Some(profile)).await?; - let id = response["id"] - .as_str() - .ok_or_else(|| { - Error::new( - 502, - "Keycloak created the user but did not return its ID. Reload the page.", - ) - })? - .to_owned(); - for group in array(&body["groups"]) { - change_role(&id, string(group), Method::POST).await?; - } - if setup == "email" { - call( - &format!("/users/{id}/execute-actions-email"), - Method::PUT, - Some(actions), + if parts.is_empty() && method == Method::POST { + let mut profile = profile(&body["profile"], true)?; + let setup = string(&body["setup"]["kind"]); + if setup != "invite" && setup != "password" { + return Err(Error::new(400, "Choose an invitation or a password.")); + } + let hash = if setup == "password" { + Some( + app.auth + .hash_password(password(&body["setup"]["password"])?) + .await?, + ) + } else { + None + }; + if !body["groups"].is_array() { + return Err(Error::new(400, "Choose groups.")); + } + let id = uuid::Uuid::new_v4().to_string(); + profile["enabled"] = json!(true); + profile["emailVerified"] = json!(false); + profile["requiredActions"] = json!([if hash.is_some() { + "UPDATE_PASSWORD" + } else { + "SETUP" + }]); + profile["createdTimestamp"] = json!((now() * 1000.0) as i64); + profile["attributes"] = json!({}); + { + let mut db = app.auth.db.lock().unwrap(); + let transaction = db.transaction()?; + transaction + .execute( + "INSERT INTO users(id,profile) VALUES (?,?)", + sql![id, profile.to_string()], ) - .await?; + .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?; + for group in array(&body["groups"]) { + let group = string(group); + uuid(group)?; + if transaction.execute( + "INSERT OR IGNORE INTO memberships SELECT ?,id FROM roles WHERE id=?", + sql![id, group], + )? == 0 + { + return Err(Error::new(400, "Choose an available group.")); + } + } + if let Some(hash) = &hash { + auth::set_password(&transaction, &id, hash)?; + } + transaction.commit()?; + } + return Ok((StatusCode::CREATED,axum::Json(json!({"id":id,"url":if setup=="invite" {Some(app.auth.setup_link(&id)?)}else{None}}))).into_response()); + } + let id = parts + .first() + .ok_or_else(|| Error::new(404, "No user here."))?; + uuid(id)?; + if *parts == [*id, "setup-link"] && method == Method::POST { + return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response()); + } + let hash = if *parts == [*id, "password"] && method == Method::PUT { + Some(app.auth.hash_password(password(&body["password"])?).await?) + } else { + None + }; + let mut db = app.auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let mut user = auth::user(&transaction, id)?; + let own = user["username"] == me["name"]; + let value = match parts { + [_] if method == Method::PATCH => { + let patch = profile(&body, false)?; + if own && patch["enabled"] == false { + return Err(Error::new( + 400, + "Sign in as another admin to disable your account.", + )); + } + if patch.get("username").is_some() && patch["username"] != user["username"] { + return Err(Error::new( + 400, + "Usernames are fixed to preserve service identities.", + )); + } + user.as_object_mut() + .unwrap() + .extend(patch.as_object().unwrap().clone()); + auth::save_user(&transaction, id, user)?; + Value::Null + } + [_] if method == Method::DELETE => { + if own { + return Err(Error::new( + 400, + "Sign in as another admin to delete your account.", + )); + } + transaction.execute( + "DELETE FROM pending WHERE json_extract(data,'$.user')=?", + [id], + )?; + transaction.execute("DELETE FROM users WHERE id=?", [id])?; + Value::Null + } + [_, "groups", group] if method == Method::PUT || method == Method::DELETE => { + let name: Option = transaction + .query_row("SELECT name FROM roles WHERE id=?", [group], |r| r.get(0)) + .optional()?; + let name = name + .ok_or_else(|| Error::new(404, "This group no longer exists. Reload the page."))?; + if own && method == Method::DELETE && name == "infra-admin" { + return Err(Error::new( + 400, + "Sign in as another admin to remove your admin access.", + )); + } + if method == Method::PUT { + transaction.execute( + "INSERT OR IGNORE INTO memberships VALUES (?,?)", + sql![id, group], + )?; } else { - call(&format!("/users/{id}/reset-password"),Method::PUT,Some(json!({"type":"password","value":body["setup"]["password"],"temporary":true}))).await?; + transaction.execute( + "DELETE FROM memberships WHERE user_id=? AND role_id=?", + sql![id, group], + )?; } - app.cache.invalidate("users"); - return Ok((StatusCode::CREATED, axum::Json(json!({"id":id}))).into_response()); - } - [id] if method == Method::PATCH => { - let profile = profile(&body, false)?; - if profile["enabled"] == false { - spare(me, id, None).await?; - } - call(&format!("/users/{id}"), Method::PUT, Some(profile)).await?; - Value::Null - } - [id] if method == Method::DELETE => { - spare(me, id, None).await?; - call(&format!("/users/{id}"), Method::DELETE, None).await?; - Value::Null - } - [id, "groups", group] if method == Method::PUT || method == Method::DELETE => { - uuid(group)?; - if method == Method::DELETE { - let groups = get("/roles").await?; - let name = array(&groups) - .iter() - .find(|g| g["id"] == *group) - .map(|g| string(&g["name"])); - spare(me, id, name).await?; - } - change_role( - id, - group, - if method == Method::PUT { - Method::POST - } else { - Method::DELETE - }, - ) - .await?; Value::Null } - [id, "credentials"] if method == Method::GET => { - get(&format!("/users/{id}/credentials")).await? - } - [id, "logout"] if method == Method::POST => { - call(&format!("/users/{id}/logout"), Method::POST, None).await?; + [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?, + [_, "logout"] if method == Method::POST => { + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; Value::Null } - [id, "actions-email"] if method == Method::POST => { - let user = found(id).await?; - if user["email"].is_null() { - return Err(Error::new(400, "Add an email address first")); - } - if array(&user["requiredActions"]).is_empty() { - return Err(Error::new(400, "Pick at least one required action first")); - } - call( - &format!("/users/{id}/execute-actions-email"), - Method::PUT, - Some(user["requiredActions"].clone()), - ) - .await?; + [_, "setup-link"] if method == Method::DELETE => { + transaction.execute( + "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?", + [id], + )?; Value::Null } - [id, "password"] if method == Method::PUT => { - let password = password(&body["password"])?; + [_, "password"] if method == Method::PUT => { if !body["temporary"].is_boolean() { return Err(Error::new( 400, "Choose whether this password is temporary.", )); } - call( - &format!("/users/{id}/reset-password"), - Method::PUT, - Some(json!({"type":"password","value":password,"temporary":body["temporary"]})), - ) - .await?; + auth::set_password(&transaction, id, hash.as_deref().unwrap())?; + user["requiredActions"] = if body["temporary"] == true { + json!(["UPDATE_PASSWORD"]) + } else { + json!([]) + }; + auth::save_user(&transaction, id, user)?; + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; Value::Null } - _ => return Err(Error::new(404, "Not Found")), + _ => return Err(Error::new(404, "No account action here.")), }; if method != Method::GET { - app.cache.invalidate("users"); + transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration','handoff') AND json_extract(data,'$.user')=?",[id])?; + if body["enabled"] == false { + transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; + transaction.execute( + "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?", + [id], + )?; + } + } + transaction.commit()?; + drop(db); + if body["enabled"] == false + || hash.is_some() + || (method == Method::DELETE && !matches!(parts, [_, "setup-link"])) + || matches!(parts, [_, "logout"]) + { + revoke_connections(&app, id)?; } Ok(if value.is_null() { StatusCode::NO_CONTENT.into_response() @@ -306,54 +327,6 @@ pub async fn route( Document::new(value).response() }) } -async fn change_role(id: &str, group: &str, method: Method) -> Result<()> { - let roles = get("/roles").await?; - let role = array(&roles) - .iter() - .find(|g| g["id"] == group) - .ok_or_else(|| Error::new(404, "That role is no longer available. Reload the page."))?; - call( - &format!("/users/{id}/role-mappings/realm"), - method, - Some(json!([role])), - ) - .await?; - Ok(()) -} -pub async fn self_user(app: &App, me: &Value) -> Result { - let name = string(&me["name"]).to_owned(); - let value = app - .cache - .coalesce(format!("identity:{name}"), move || async move { - let found = get(&format!("/users?username={}&exact=true", encoded(&name))).await?; - let mut user = array(&found) - .iter() - .find(|u| u["username"] == name) - .cloned() - .ok_or_else(|| { - Error::new( - 404, - format!( - "Keycloak has no user named {}. Sign out, then sign in again.", - name - ), - ) - })?; - user["groups"] = get(&format!( - "/users/{}/role-mappings/realm", - encoded(string(&user["id"])) - )) - .await?; - for key in ["email", "firstName", "lastName"] { - if user.get(key).is_none() { - user[key] = Value::Null; - } - } - Ok(user) - }) - .await?; - Ok(value.value.clone()) -} fn image_type(bytes: &[u8]) -> Option<&'static str> { if bytes.get(..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { Some("image/webp") @@ -396,130 +369,62 @@ pub async fn account( me: &Value, ) -> Result { let method = request.method().clone(); - let headers = request.headers(); - let origin = format!( - "{}://{}", - headers - .get("X-Forwarded-Proto") - .and_then(|h| h.to_str().ok()) - .unwrap_or("http"), - headers - .get("X-Forwarded-Host") - .or(headers.get("Host")) - .and_then(|h| h.to_str().ok()) - .unwrap_or("localhost") - ); - let realm = || { - std::env::var("STUDIO_KEYCLOAK_URL") - .map(|s| format!("{s}/realms/master")) - .map_err(|_| { - Error::new( - 501, - "Keycloak isn't connected to this home server. Connect it, then retry.", - ) - }) - }; - if parts == ["sign-out"] && method == Method::GET { - let logout = format!( - "{}/protocol/openid-connect/logout?{}", - realm()?, - params(&[ - ("client_id", "forward-auth".into()), - ("post_logout_redirect_uri", format!("{origin}/")) - ]) - ); - return Ok(( - StatusCode::FOUND, - [( - "location", - format!("/snow.oauth2/sign_out?{}", params(&[("rd", logout)])), - )], - ) - .into_response()); - } - if let ["actions", action] = parts { - if method != Method::GET - || (![ - "webauthn-register-passwordless", - "UPDATE_PASSWORD", - "UPDATE_EMAIL", - ] - .contains(action) - && !regex::Regex::new(r"^delete_credential:[\w-]+$") - .unwrap() - .is_match(action)) - { - return Err(Error::new( - 400, - "Keycloak can't start that action from here", - )); - } - return Ok(( - StatusCode::FOUND, - [( - "location", - format!( - "{}/protocol/openid-connect/auth?{}", - realm()?, - params(&[ - ("client_id", "forward-auth".into()), - ("redirect_uri", format!("{origin}/account")), - ("response_type", "code".into()), - ("scope", "openid".into()), - ("kc_action", action.to_string()) - ]) - ), - )], - ) - .into_response()); - } let mut user = self_user(&app, me).await?; let id = string(&user["id"]).to_owned(); let value = match parts { [] if method == Method::GET => { - let attributes = user - .as_object_mut() - .unwrap() - .remove("attributes") - .unwrap_or(Value::Null); - user["picture"] = attributes["picture"][0].clone(); - user["credentials"] = get(&format!("/users/{id}/credentials")).await?; - user["console"] = json!(format!("{}/account", realm()?)); + user["picture"] = user["attributes"]["picture"][0].clone(); + user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?; + user.as_object_mut().unwrap().remove("attributes"); user } [] if method == Method::PATCH => { - let body: Value = serde_json::from_slice( - &axum::body::to_bytes(request.into_body(), 1024 * 1024).await?, - ) - .map_err(|_| Error::new(400, "Invalid profile."))?; - let mut value = serde_json::Map::new(); - for key in ["firstName", "lastName"] { - if let Some(v) = body.get(key) { - let v = v - .as_str() - .ok_or_else(|| Error::new(400, "Enter a name."))? - .trim(); - value.insert( - key.into(), - if v.is_empty() { Value::Null } else { json!(v) }, - ); + let body: Value = + serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?; + for key in ["firstName", "lastName", "email"] { + if body.get(key).is_some() { + let mut field = serde_json::Map::new(); + field.insert(key.to_owned(), body[key].clone()); + let patch = profile(&Value::Object(field), false)?; + user[key] = patch[key].clone(); + if key == "email" { + user["emailVerified"] = json!(false); + } } } - call( - &format!("/users/{id}"), - Method::PUT, - Some(Value::Object(value)), - ) - .await?; + user["requiredActions"] = json!( + array(&user["requiredActions"]) + .iter() + .filter(|v| **v != "UPDATE_PROFILE") + .collect::>() + ); + auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; Value::Null } - ["verify-email"] if method == Method::POST => { - call( - &format!("/users/{id}/execute-actions-email"), - Method::PUT, - Some(json!(["VERIFY_EMAIL"])), - ) - .await?; + ["credentials", credential] if method == Method::DELETE => { + app.auth.recent(request.headers())?; + let mut db = app.auth.db.lock().unwrap(); + let transaction = db.transaction()?; + let count: i64 = transaction.query_row( + "SELECT count(*) FROM credentials WHERE user_id=?", + [&id], + |r| r.get(0), + )?; + if count <= 1 { + return Err(Error::new( + 400, + "Add another sign-in method before removing this one.", + )); + } + if transaction.execute( + "DELETE FROM credentials WHERE user_id=? AND id=?", + sql![id, credential], + )? == 0 + { + return Err(Error::new(404, "This sign-in method was already removed.")); + } + transaction.execute("DELETE FROM pending WHERE kind IN ('authentication','registration') AND json_extract(data,'$.user')=?",[&id])?; + transaction.commit()?; Value::Null } ["picture"] if method == Method::PUT => { @@ -554,32 +459,22 @@ pub async fn account( tokio::fs::create_dir_all(app.data.join("pictures")).await?; tokio::fs::write(app.data.join("pictures").join(&id), bytes).await?; let picture = format!( - "{origin}/api/account/pictures/{id}?v={}", + "{}/api/account/pictures/{id}?v={}", + app.auth.origin.origin().ascii_serialization(), (now() * 1000.0) as u64 ); - call( - &format!("/users/{id}"), - Method::PUT, - Some(json!({"attributes":{"picture":[picture]}})), - ) - .await?; + user["attributes"]["picture"] = json!([picture]); + auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; json!({"picture":picture}) } ["picture"] if method == Method::DELETE => { - call( - &format!("/users/{id}"), - Method::PUT, - Some(json!({"attributes":{"picture":null}})), - ) - .await?; + user["attributes"]["picture"] = Value::Null; + auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; let _ = tokio::fs::remove_file(app.data.join("pictures").join(id)).await; Value::Null } - _ => return Err(Error::new(404, "Not Found")), + _ => return Err(Error::new(404, "No account action here.")), }; - if method != Method::GET { - app.cache.invalidate("users"); - } Ok(if value.is_null() { StatusCode::NO_CONTENT.into_response() } else { diff --git a/dashboard/web/api.contract.ts b/dashboard/web/api.contract.ts index 8f0dd97eac896786849c5741237f933321d6ffaf..9442e1ef8e9fc198137eb4e8f4e2f4521b8dacad 100644 --- a/dashboard/web/api.contract.ts +++ b/dashboard/web/api.contract.ts @@ -1,4 +1,4 @@ -import type { Connections, Consent } from "./types/mcp.ts"; +import type { Access, Connections, Consent, Resources } from "./types/mcp.ts"; import type { Pool, Vdev, Disk, Dataset, Snapshot } from "./types/storage.ts"; import type { Torrent, TorrentFile, ServerState } from "./types/seedbox.ts"; import type { Video, Show, Job, Wall, Archive, Upscaler, Channels, ConfigFile, LibraryEntry, Ingest } from "./types/youtube.ts"; @@ -55,10 +55,10 @@ type ExplorerRoutes = { [P in keyof Explorer as `${Prefix export type Api = Hono<{}, { "/mcp": { $get: Endpoint; }; - "/mcp/shale": { $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint; }; + "/mcp/shale": { $get: Endpoint<{ linked: boolean; resources: Resources }>; $post: Endpoint<{ redirect: string }, { json: { request?: string } }>; $delete: Endpoint; }; "/mcp/consent/:id": { $get: Endpoint; - $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: string[] } | { deny: true } }>; + $post: Endpoint<{ redirect: string }, { param: { id: string }; json: { resources: Access } | { deny: true } }>; }; "/mcp/relay/pair": { $post: Endpoint; }; "/mcp/relay/machines/:id": { @@ -66,7 +66,11 @@ export type Api = Hono<{}, { $delete: Endpoint; }; "/mcp/relay/keys": { $post: Endpoint<{ key: string; id: string }, { json: { name: string; resources: string[]; write: boolean } }>; }; - "/mcp/connections/:id": { $delete: Endpoint; }; + "/mcp/connections/:id": { + $get: Endpoint<{ resources: Resources; selected: Access; linked: boolean; resourceError: string | null }, { param: { id: string } }>; + $post: Endpoint; + $delete: Endpoint; + }; "/me": { $get: Endpoint; @@ -210,7 +214,7 @@ export type Api = Hono<{}, { }; "/users": { $get: Endpoint<{users:(User & {sessions:Session[]})[]; groups:Group[]}>; - $post: Endpoint<{ id: string; }, { json: { profile: { email: string; firstName: string; lastName: string; username: string; }; groups: string[]; setup: { kind: "email"; } | { kind: "password"; password: string; }; }; }, 201>; + $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>; }; "/users/:id": { $patch: Endpoint; @@ -226,8 +230,9 @@ export type Api = Hono<{}, { "/users/:id/logout": { $post: Endpoint; }; - "/users/:id/actions-email": { - $post: Endpoint; + "/users/:id/setup-link": { + $post: Endpoint<{url:string}, { param: { id: string; }; }>; + $delete: Endpoint; }; "/users/:id/password": { $put: Endpoint; @@ -283,11 +288,8 @@ export type Api = Hono<{}, { $post: Endpoint; }; "/account": { - $get: Endpoint; - $patch: Endpoint; - }; - "/account/verify-email": { - $post: Endpoint; + $get: Endpoint; + $patch: Endpoint; }; "/account/picture": { $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; @@ -296,10 +298,7 @@ export type Api = Hono<{}, { "/account/pictures/:id": { $get: Endpoint; }; - "/account/actions/:action": { - $get: Endpoint; - }; - "/account/sign-out": { - $get: Endpoint; + "/account/credentials/:id": { + $delete: Endpoint; }; } & ExplorerRoutes<"/media"> & ExplorerRoutes<"/storage/files">>; diff --git a/dashboard/web/auth.ts b/dashboard/web/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..3997cdefbe9bb7aa2301750785d88f9a7ad41da6 --- /dev/null +++ b/dashboard/web/auth.ts @@ -0,0 +1,24 @@ +export async function authRequest(path: string, body?: object): Promise { + const response = await fetch(`/auth/${path}`, body ? { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), + } : undefined); + if (!response.ok) throw new DetailedError(response.statusText, { statusCode: response.status, detail: { data: await response.text() } }); + return response.status === 204 ? undefined as T : response.json(); +} + +export async function addPasskey(label: string) { + const { csrf } = await authRequest<{ csrf: string }>("status"); + const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialCreationOptionsJSON }; token: string }>("passkey/register", { csrf }); + const credential = await navigator.credentials.create({ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.publicKey) }); + if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey setup was canceled. Try again when you're ready."); + await authRequest("passkey/save", { csrf, token, credential: credential.toJSON(), label }); +} + +export async function signOut() { + await authRequest("sign-out", {}); + window.location.assign("/sign-in"); +} + +export const authReason = (failure: unknown) => failure instanceof DetailedError ? reason(failure) : failure instanceof Error ? failure.message : "Couldn't finish sign-in. Try again."; +import { DetailedError } from "hono/client"; +import { reason } from "./api.ts"; diff --git a/dashboard/web/components/Sidebar.tsx b/dashboard/web/components/Sidebar.tsx index 070f4901e5afdb5ca01c9c437ce2fd646a2dd289..2651f9c881a4bdab0da55ba70567d07e8ad1bfb4 100644 --- a/dashboard/web/components/Sidebar.tsx +++ b/dashboard/web/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts"; import { queries } from "../api.ts"; import snowflake from "../snowflake.svg"; import { bytes, cores, plural } from "../format.ts"; +import { signOut } from "../auth.ts"; import { account, Avatar, displayName } from "../pages/Account.tsx"; import { status } from "../pages/Overview.tsx"; import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; @@ -243,7 +244,7 @@ function Whoami(props: { me: Me }) {