From c94ec9e2b83ad024438187a4f1cc83c885544a98 Mon Sep 17 00:00:00 2001 From: Cody Wellman Date: Sun, 19 Jul 2026 18:04:01 -0400 Subject: [PATCH 1/2] Add device enrollment token system: gRPC gateway, device CA, token management UI, and grouped backend modules --- backend/.env.example | 23 + backend/Cargo.lock | 702 +++++++++++++++++- backend/Cargo.toml | 19 + backend/build.rs | 17 + backend/migrations/0003_device_enrollment.sql | 79 ++ .../quartzcommand/device/v1/device.proto | 25 + .../enrollment/v1/enrollment.proto | 39 + .../{admin_accounts.rs => admin/accounts.rs} | 2 +- backend/src/{admin_auth.rs => admin/auth.rs} | 2 +- backend/src/admin/mod.rs | 6 + backend/src/{admin_orgs.rs => admin/orgs.rs} | 2 +- backend/src/audit.rs | 44 ++ backend/src/config.rs | 38 + backend/src/{ => console}/auth.rs | 0 backend/src/console/devices.rs | 80 ++ backend/src/console/enroll_tokens.rs | 217 ++++++ backend/src/console/mod.rs | 7 + backend/src/{ => console}/organizations.rs | 3 +- backend/src/gateway/clone_detect.rs | 147 ++++ backend/src/gateway/device.rs | 162 ++++ backend/src/gateway/enrollment.rs | 370 +++++++++ backend/src/gateway/mod.rs | 99 +++ backend/src/gateway/pb.rs | 14 + backend/src/gateway/ratelimit.rs | 63 ++ backend/src/lib.rs | 43 ++ backend/src/main.rs | 114 +-- backend/src/models.rs | 31 + backend/src/pki/ca.rs | 302 ++++++++ backend/src/pki/deviceid.rs | 81 ++ backend/src/pki/mod.rs | 5 + backend/tests/enrollment.rs | 602 +++++++++++++++ .../[organization_guid]/inventory/page.tsx | 300 +++++++- frontend/components/AddDeviceModal.tsx | 241 ++++++ frontend/lib/api.ts | 79 ++ 34 files changed, 3898 insertions(+), 60 deletions(-) create mode 100644 backend/build.rs create mode 100644 backend/migrations/0003_device_enrollment.sql create mode 100644 backend/proto/quartzcommand/device/v1/device.proto create mode 100644 backend/proto/quartzcommand/enrollment/v1/enrollment.proto rename backend/src/{admin_accounts.rs => admin/accounts.rs} (98%) rename backend/src/{admin_auth.rs => admin/auth.rs} (98%) create mode 100644 backend/src/admin/mod.rs rename backend/src/{admin_orgs.rs => admin/orgs.rs} (99%) create mode 100644 backend/src/audit.rs rename backend/src/{ => console}/auth.rs (100%) create mode 100644 backend/src/console/devices.rs create mode 100644 backend/src/console/enroll_tokens.rs create mode 100644 backend/src/console/mod.rs rename backend/src/{ => console}/organizations.rs (98%) create mode 100644 backend/src/gateway/clone_detect.rs create mode 100644 backend/src/gateway/device.rs create mode 100644 backend/src/gateway/enrollment.rs create mode 100644 backend/src/gateway/mod.rs create mode 100644 backend/src/gateway/pb.rs create mode 100644 backend/src/gateway/ratelimit.rs create mode 100644 backend/src/lib.rs create mode 100644 backend/src/pki/ca.rs create mode 100644 backend/src/pki/deviceid.rs create mode 100644 backend/src/pki/mod.rs create mode 100644 backend/tests/enrollment.rs create mode 100644 frontend/components/AddDeviceModal.tsx diff --git a/backend/.env.example b/backend/.env.example index 9d9c510..ca16f02 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -23,6 +23,29 @@ QC_SESSION_HOURS=24 QC_JWT_SECRET_FILE=./data/jwt.secret QC_ADMIN_JWT_SECRET_FILE=./data/admin-jwt.secret +# ── Device gateway (gRPC) ──────────────────────────────────────────────────── +# Address the device gateway (enrollment + mTLS device services) binds to. +#QC_GRPC_LISTEN=127.0.0.1:8443 + +# Public host:port devices reach the gateway at — embedded in enrollment +# tokens and returned as assigned_gateway. Defaults to QC_GRPC_LISTEN (dev +# only); set this to the real DNS name in any deployment. +#QC_GATEWAY_ADDR=gateway.example.com:8443 + +# Directory holding the internal device CA (key + cert, generated on first +# run). Device client certs are issued from this CA. +#QC_DEVICE_CA_DIR=./data/device-ca + +# Gateway TLS server cert/key (PEM). Set both to serve TLS with optional +# client certs (required for cert renewal); leave unset for a plaintext dev +# listener (enrollment works, mTLS device services are disabled). +#QC_GRPC_TLS_CERT_FILE= +#QC_GRPC_TLS_KEY_FILE= + +# Cert (PEM or DER) of the CA that issued the gateway's TLS cert; its SHA-256 +# goes into enrollment tokens. Defaults to the device CA cert. +#QC_GATEWAY_CA_FILE= + # ── Default admin ──────────────────────────────────────────────────────────── # Seeded on startup ONLY when the `admins` table is empty. Use it to get into # /admin/login on a fresh database, then change the password. Leave unset to diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e149398..ced3ed8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -44,6 +44,67 @@ dependencies = [ "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", + "synstructure", +] + +[[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", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -104,7 +165,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -143,6 +204,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bitflags" version = "2.13.1" @@ -288,6 +355,39 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -299,6 +399,20 @@ dependencies = [ "zeroize", ] +[[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" @@ -334,6 +448,31 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -381,12 +520,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flume" version = "0.11.1" @@ -398,6 +555,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -518,6 +681,31 @@ dependencies = [ "r-efi", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -638,6 +826,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -646,6 +835,20 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] @@ -655,12 +858,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2 0.6.5", "tokio", "tower-service", + "tracing", ] [[package]] @@ -790,6 +998,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -800,6 +1018,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -875,6 +1102,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -896,6 +1129,39 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "matchers" version = "0.2.0" @@ -927,12 +1193,40 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[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.2" @@ -944,6 +1238,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[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 = "nu-ansi-term" version = "0.50.3" @@ -1014,6 +1324,15 @@ dependencies = [ "libm", ] +[[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" @@ -1085,6 +1404,36 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1148,6 +1497,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1157,6 +1516,98 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost", + "prost-types", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost", + "prost-reflect", + "prost-types", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types", + "thiserror 1.0.69", +] + [[package]] name = "quartz-command" version = "0.1.0" @@ -1164,22 +1615,32 @@ dependencies = [ "anyhow", "argon2", "axum", + "base64", "chrono", "dotenvy", + "ed25519-dalek", "jsonwebtoken", "password-hash", + "prost", + "protox", "rand", + "rcgen", "rpassword", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", + "time", "tokio", - "tower", + "tonic", + "tonic-build", + "tower 0.5.3", "tower-http", "tracing", "tracing-subscriber", "uuid", + "x509-parser", ] [[package]] @@ -1227,6 +1688,20 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1245,6 +1720,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.16" @@ -1317,12 +1804,44 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +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 = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1331,6 +1850,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -1369,6 +1897,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1519,6 +2053,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.5" @@ -1580,7 +2124,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -1797,6 +2341,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1913,7 +2470,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -1929,6 +2486,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1940,6 +2507,85 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-pemfile", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -2046,6 +2692,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2079,6 +2731,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" @@ -2133,6 +2791,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2440,6 +3107,33 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[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", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 65b4b61..7865da5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -37,7 +37,26 @@ sqlx = { version = "0.8", default-features = false, features = [ uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } rand = "0.8" +# Device enrollment gateway: gRPC (tonic) + the device PKI. +tonic = { version = "0.12", features = ["tls"] } +prost = "0.13" +# Ed25519 verification of device proof-of-possession signatures. +ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } +sha2 = "0.10" +base64 = "0.22" +# Device CA: issue mTLS client certs from device CSRs. +rcgen = { version = "0.13", features = ["pem", "x509-parser"] } +# Parse/verify device CSRs and presented client certs. +x509-parser = { version = "0.16", features = ["verify"] } +# rcgen's validity timestamps are time::OffsetDateTime. +time = "0.3" # Reads password from the terminal (no echo) for the seed subcommands. rpassword = "7" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[build-dependencies] +# protox compiles .proto files in pure Rust, so builds don't need a protoc +# binary installed (there is none on the Windows dev machines or CI runners). +tonic-build = "0.12" +protox = "0.7" diff --git a/backend/build.rs b/backend/build.rs new file mode 100644 index 0000000..34f1e03 --- /dev/null +++ b/backend/build.rs @@ -0,0 +1,17 @@ +//! Compile the gateway .proto files with protox (pure Rust — no protoc binary +//! needed on dev machines or CI) and generate tonic server/client code. + +fn main() -> Result<(), Box> { + let protos = [ + "proto/quartzcommand/enrollment/v1/enrollment.proto", + "proto/quartzcommand/device/v1/device.proto", + ]; + for p in &protos { + println!("cargo:rerun-if-changed={p}"); + } + + let fds = protox::compile(protos, ["proto"])?; + // Client code is generated too: the enrollment tests act as a device. + tonic_build::configure().compile_fds(fds)?; + Ok(()) +} diff --git a/backend/migrations/0003_device_enrollment.sql b/backend/migrations/0003_device_enrollment.sql new file mode 100644 index 0000000..820c2dc --- /dev/null +++ b/backend/migrations/0003_device_enrollment.sql @@ -0,0 +1,79 @@ +-- Device enrollment: controller-issued enrollment tokens, adopted devices, +-- short-lived enrollment sessions (nonce store), plus org-scoped events and an +-- audit trail. Cloud-side half of QuartzFire device adoption. + +-- Controller-issued enrollment tokens. Only the Argon2id hash of the secret +-- half is stored; the full QC1|… string is shown exactly once at creation. +CREATE TABLE enrollment_tokens ( + token_id text PRIMARY KEY, -- "tok_…", URL-safe + org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + secret_hash text NOT NULL, -- Argon2id PHC string + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, -- default 24h, set by the API + max_uses integer, -- NULL = unlimited + use_count integer NOT NULL DEFAULT 0, + revoked_at timestamptz, + label text +); +CREATE INDEX enrollment_tokens_org_idx ON enrollment_tokens (org_id); + +-- Adopted (or revoked) QuartzFire devices. device_id is derived from the +-- device's Ed25519 public key ("QF-" + Crockford base32(SHA256(pubkey))[0:16]), +-- so the same key always maps to the same device. +CREATE TABLE devices ( + device_id text PRIMARY KEY, -- QF-XXXX-XXXX-XXXX-XXXX + org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + pubkey bytea NOT NULL, -- raw 32-byte Ed25519 public key + cert_serial text, -- hex serial of the current client cert + cert_not_after timestamptz, + state text NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'adopted', 'revoked')), + enrolled_at timestamptz, + enrolled_via_token text, -- token_id; not a FK so token deletion keeps history + hostname text, + qf_version text, + last_seen_at timestamptz, + last_seen_ip text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX devices_org_idx ON devices (org_id); + +-- Short-lived (5 min) server-side nonce store for in-flight enrollments. +-- Expired rows are cleaned up opportunistically on BeginEnrollment. +CREATE TABLE enrollment_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + token_id text NOT NULL, -- validated again at completion + device_pubkey bytea NOT NULL, + nonce bytea NOT NULL, -- 32 CSPRNG bytes the device must sign + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); +CREATE INDEX enrollment_sessions_expires_idx ON enrollment_sessions (expires_at); + +-- Org-visible operational events (e.g. "Possible cloned device"). First pass +-- of a notification system; the console can list these per organization. +CREATE TABLE org_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + severity text NOT NULL DEFAULT 'info' + CHECK (severity IN ('info', 'warning', 'critical')), + title text NOT NULL, + details jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX org_events_org_idx ON org_events (org_id, created_at DESC); + +-- Append-only audit trail for the enrollment/PKI surface: token +-- created/revoked, enrollment succeeded/failed (with reason), cert +-- issued/renewed, device revoked. org_id is nullable so failures against +-- unknown tokens can still be recorded. +CREATE TABLE audit_log ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid REFERENCES organizations(id) ON DELETE SET NULL, + actor text NOT NULL, -- "user:", "device:", "system" + action text NOT NULL, -- e.g. "enrollment.failed" + details jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX audit_log_org_idx ON audit_log (org_id, created_at DESC); diff --git a/backend/proto/quartzcommand/device/v1/device.proto b/backend/proto/quartzcommand/device/v1/device.proto new file mode 100644 index 0000000..a86d30d --- /dev/null +++ b/backend/proto/quartzcommand/device/v1/device.proto @@ -0,0 +1,25 @@ +// mTLS-authenticated device service. The caller's identity (device_id, org, +// public key) comes from the presented client certificate, never from the +// request body. +syntax = "proto3"; + +package quartzcommand.device.v1; + +service DeviceService { + // Issue a fresh client certificate for the presented identity. The CSR must + // carry the same Ed25519 public key and CN=device_id as the current cert. + // Devices are expected to renew at 2/3 of cert lifetime (see + // renew_after_unix in the response). + rpc RenewCertificate(RenewCertificateRequest) returns (RenewCertificateResponse); +} + +message RenewCertificateRequest { + bytes csr_der = 1; +} + +message RenewCertificateResponse { + bytes client_cert_der = 1; + repeated bytes ca_chain_der = 2; + int64 not_after_unix = 3; // expiry of the new cert + int64 renew_after_unix = 4; // 2/3 of lifetime — renew at/after this time +} diff --git a/backend/proto/quartzcommand/enrollment/v1/enrollment.proto b/backend/proto/quartzcommand/enrollment/v1/enrollment.proto new file mode 100644 index 0000000..0515a5c --- /dev/null +++ b/backend/proto/quartzcommand/enrollment/v1/enrollment.proto @@ -0,0 +1,39 @@ +// Device enrollment bootstrap. Served WITHOUT a client-cert requirement (the +// device has no cert yet); every other gateway service requires mTLS. The +// QuartzFire firmware implements the client side of exactly this contract — +// do not rename fields. +syntax = "proto3"; + +package quartzcommand.enrollment.v1; + +service EnrollmentService { + rpc BeginEnrollment(BeginEnrollmentRequest) returns (BeginEnrollmentResponse); + rpc CompleteEnrollment(CompleteEnrollmentRequest) returns (CompleteEnrollmentResponse); +} + +message BeginEnrollmentRequest { + string token_id = 1; + bytes device_pubkey = 2; +} + +message BeginEnrollmentResponse { + bytes nonce = 1; + string enrollment_session_id = 2; +} + +message CompleteEnrollmentRequest { + string enrollment_session_id = 1; + string token_secret = 2; // plaintext secret half of the token + string device_id = 3; // must match derivation from device_pubkey + bytes nonce_signature = 4; // Ed25519 sig over nonce + bytes csr_der = 5; // CSR, CN=device_id + string hostname = 6; // informational + string qf_version = 7; // informational +} + +message CompleteEnrollmentResponse { + bytes client_cert_der = 1; + repeated bytes ca_chain_der = 2; + string assigned_gateway = 3; // host:port for the device's control channel + string org_id = 4; +} diff --git a/backend/src/admin_accounts.rs b/backend/src/admin/accounts.rs similarity index 98% rename from backend/src/admin_accounts.rs rename to backend/src/admin/accounts.rs index 602725b..0bcb3de 100644 --- a/backend/src/admin_accounts.rs +++ b/backend/src/admin/accounts.rs @@ -1,5 +1,5 @@ //! Admin-account management for the admin console's Settings → Users tab. -//! All routes sit behind `admin_auth::require_admin`. Two guards keep the +//! All routes sit behind `admin::auth::require_admin`. Two guards keep the //! console from locking itself out: an admin can never delete or deactivate //! their own account, and the last active admin can never be removed. diff --git a/backend/src/admin_auth.rs b/backend/src/admin/auth.rs similarity index 98% rename from backend/src/admin_auth.rs rename to backend/src/admin/auth.rs index 5aded0a..6ad3b2a 100644 --- a/backend/src/admin_auth.rs +++ b/backend/src/admin/auth.rs @@ -1,7 +1,7 @@ //! Admin realm authentication (`/api/admin/auth/*`) — the `/admin/login` + //! `/admin` console. //! -//! Structurally identical to `auth.rs` but verifies against the separate +//! Structurally identical to `console/auth.rs` but verifies against the separate //! `admins` table, uses its own cookie (`qc_admin_session`) and its own JWT //! signing secret. Both the distinct secret and the `realm` claim ensure a user //! session can never satisfy admin auth (and vice versa). diff --git a/backend/src/admin/mod.rs b/backend/src/admin/mod.rs new file mode 100644 index 0000000..9599cb3 --- /dev/null +++ b/backend/src/admin/mod.rs @@ -0,0 +1,6 @@ +//! Admin console (`/admin` realm): platform-administrator auth plus +//! management of admin accounts and tenant organizations. + +pub mod accounts; +pub mod auth; +pub mod orgs; diff --git a/backend/src/admin_orgs.rs b/backend/src/admin/orgs.rs similarity index 99% rename from backend/src/admin_orgs.rs rename to backend/src/admin/orgs.rs index e16aa52..c5620e0 100644 --- a/backend/src/admin_orgs.rs +++ b/backend/src/admin/orgs.rs @@ -1,5 +1,5 @@ //! Organization + user management for the admin console. All routes sit -//! behind `admin_auth::require_admin`, so callers are platform administrators; +//! behind `admin::auth::require_admin`, so callers are platform administrators; //! there is no tenant scoping here — admins see every organization. use axum::{ diff --git a/backend/src/audit.rs b/backend/src/audit.rs new file mode 100644 index 0000000..016c88d --- /dev/null +++ b/backend/src/audit.rs @@ -0,0 +1,44 @@ +//! Audit trail + org event helpers for the enrollment/PKI surface. +//! +//! Audit writes are best-effort: a failed insert is logged loudly but never +//! fails the operation being audited (an enrollment must not break because +//! the audit table hiccuped). + +use serde_json::Value; +use sqlx::PgPool; +use uuid::Uuid; + +/// Append an audit entry. `actor` is `"user:"`, `"device:"`, or +/// `"system"`; `org_id` is None when the event can't be tied to an org (e.g. +/// enrollment against an unknown token). +pub async fn record(db: &PgPool, org_id: Option, actor: &str, action: &str, details: Value) { + let res = sqlx::query( + "INSERT INTO audit_log (org_id, actor, action, details) VALUES ($1, $2, $3, $4)", + ) + .bind(org_id) + .bind(actor) + .bind(action) + .bind(&details) + .execute(db) + .await; + if let Err(e) = res { + tracing::error!(action, ?details, "audit write failed: {e}"); + } +} + +/// Raise an org-visible event (first pass of a notification system). +/// `severity` is one of `info` / `warning` / `critical` (schema-enforced). +pub async fn raise_event(db: &PgPool, org_id: Uuid, severity: &str, title: &str, details: Value) { + let res = sqlx::query( + "INSERT INTO org_events (org_id, severity, title, details) VALUES ($1, $2, $3, $4)", + ) + .bind(org_id) + .bind(severity) + .bind(title) + .bind(&details) + .execute(db) + .await; + if let Err(e) = res { + tracing::error!(title, %org_id, "org event write failed: {e}"); + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs index 5b18521..04ecfd9 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -33,6 +33,27 @@ pub struct Config { /// come up with a usable `/admin/login` without a manual seed step. pub default_admin_email: Option, pub default_admin_password: Option, + + /// Address the device gateway (gRPC) binds to. + pub grpc_listen: String, + + /// Public `host:port` devices reach the gateway at — embedded in + /// enrollment tokens and returned as `assigned_gateway`. Defaults to + /// `grpc_listen` (fine for local dev only). + pub gateway_addr: String, + + /// Directory holding the device CA key + cert (generated on first start). + pub device_ca_dir: PathBuf, + + /// Gateway TLS server cert/key (PEM). Set both for TLS with optional + /// client certs; leave both unset for a plaintext dev listener. + pub grpc_tls_cert_file: Option, + pub grpc_tls_key_file: Option, + + /// Cert (PEM or DER) of the CA that issued the gateway's TLS cert; its + /// SHA-256 goes into enrollment tokens. Defaults to the device CA cert + /// (correct for self-hosted setups where the gateway cert is internal). + pub gateway_ca_file: Option, } impl Config { @@ -64,6 +85,17 @@ impl Config { let default_admin_email = non_empty("QC_DEFAULT_ADMIN_EMAIL"); let default_admin_password = non_empty("QC_DEFAULT_ADMIN_PASSWORD"); + let grpc_listen = + std::env::var("QC_GRPC_LISTEN").unwrap_or_else(|_| "127.0.0.1:8443".to_string()); + let gateway_addr = + std::env::var("QC_GATEWAY_ADDR").unwrap_or_else(|_| grpc_listen.clone()); + let device_ca_dir = std::env::var("QC_DEVICE_CA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("./data/device-ca")); + let grpc_tls_cert_file = non_empty("QC_GRPC_TLS_CERT_FILE").map(PathBuf::from); + let grpc_tls_key_file = non_empty("QC_GRPC_TLS_KEY_FILE").map(PathBuf::from); + let gateway_ca_file = non_empty("QC_GATEWAY_CA_FILE").map(PathBuf::from); + Ok(Self { database_url, listen, @@ -73,6 +105,12 @@ impl Config { session_hours, default_admin_email, default_admin_password, + grpc_listen, + gateway_addr, + device_ca_dir, + grpc_tls_cert_file, + grpc_tls_key_file, + gateway_ca_file, }) } } diff --git a/backend/src/auth.rs b/backend/src/console/auth.rs similarity index 100% rename from backend/src/auth.rs rename to backend/src/console/auth.rs diff --git a/backend/src/console/devices.rs b/backend/src/console/devices.rs new file mode 100644 index 0000000..71ca578 --- /dev/null +++ b/backend/src/console/devices.rs @@ -0,0 +1,80 @@ +//! Device endpoints for the cloud console's Inventory section. Org-scoped, +//! behind `auth::require_auth`; revoking a device requires owner/admin. + +use axum::{extract::Path, extract::State, Extension, Json}; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +use crate::{ + audit, + console::organizations::member_org, + error::{AppError, Result}, + models::Device, + security::Claims, + AppState, +}; + +fn caller_id(claims: &Claims) -> Result { + claims.sub.parse().map_err(|_| AppError::Unauthorized) +} + +/// GET /api/orgs/:organization_guid/devices — any member. +pub async fn list( + State(state): State>, + Extension(claims): Extension, + Path(organization_guid): Path, +) -> Result>> { + let uid = caller_id(&claims)?; + member_org(&state, organization_guid, uid).await?; + + let devices = sqlx::query_as::<_, Device>( + "SELECT device_id, state, hostname, qf_version, cert_serial, cert_not_after, \ + enrolled_at, enrolled_via_token, last_seen_at, last_seen_ip \ + FROM devices WHERE org_id = $1 ORDER BY enrolled_at DESC NULLS LAST, device_id", + ) + .bind(organization_guid) + .fetch_all(&state.db) + .await?; + + Ok(Json(devices)) +} + +/// POST /api/orgs/:organization_guid/devices/:device_id/revoke — owner/admin. +/// A revoked device can no longer renew its certificate; re-enrollment with a +/// fresh token (same key) is allowed and re-adopts it. +pub async fn revoke( + State(state): State>, + Extension(claims): Extension, + Path((organization_guid, device_id)): Path<(Uuid, String)>, +) -> Result> { + let uid = caller_id(&claims)?; + let org = member_org(&state, organization_guid, uid).await?; + if org.role != "owner" && org.role != "admin" { + return Err(AppError::Forbidden); + } + + let updated = sqlx::query( + "UPDATE devices SET state = 'revoked' \ + WHERE device_id = $1 AND org_id = $2 AND state <> 'revoked'", + ) + .bind(&device_id) + .bind(organization_guid) + .execute(&state.db) + .await?; + if updated.rows_affected() == 0 { + return Err(AppError::NotFound("no such active device".into())); + } + + audit::record( + &state.db, + Some(organization_guid), + &format!("user:{uid}"), + "device.revoked", + json!({ "device_id": device_id }), + ) + .await; + tracing::info!(device = %device_id, org = %organization_guid, "device revoked"); + + Ok(Json(json!({ "ok": true }))) +} diff --git a/backend/src/console/enroll_tokens.rs b/backend/src/console/enroll_tokens.rs new file mode 100644 index 0000000..3cf2f4d --- /dev/null +++ b/backend/src/console/enroll_tokens.rs @@ -0,0 +1,217 @@ +//! Enrollment-token management for the cloud console ("Add device" flow). +//! Org-scoped REST endpoints behind `auth::require_auth`; creating and +//! revoking tokens additionally requires an owner/admin role in the org. +//! +//! The full `QC1|…` token string (containing the plaintext secret) is +//! returned exactly once, from the create call. Only the Argon2id hash of +//! the secret is stored, so it can never be retrieved again. + +use axum::{extract::Path, extract::State, Extension, Json}; +use rand::RngCore; +use serde::Deserialize; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +use crate::{ + audit, + console::organizations::member_org, + error::{AppError, Result}, + models::EnrollmentTokenMeta, + security::{self, Claims}, + AppState, +}; + +/// Default token lifetime when the caller doesn't pick one. +const DEFAULT_EXPIRES_HOURS: i64 = 24; +/// Hard cap: one year. +const MAX_EXPIRES_HOURS: i64 = 8760; + +/// Parse the authenticated user's id out of the session claims. +fn caller_id(claims: &Claims) -> Result { + claims.sub.parse().map_err(|_| AppError::Unauthorized) +} + +/// Owner/admin gate for mutating token/device state. Membership itself is +/// checked first (403 for non-members regardless of org existence). +async fn require_manager( + state: &Arc, + organization_guid: Uuid, + uid: Uuid, +) -> Result<()> { + let org = member_org(state, organization_guid, uid).await?; + if org.role == "owner" || org.role == "admin" { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +/// `tok_` + 12 URL-safe chars (lowercase alphanumerics, CSPRNG-chosen). +pub fn generate_token_id() -> String { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut bytes = [0u8; 12]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let suffix: String = bytes + .iter() + .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char) + .collect(); + format!("tok_{suffix}") +} + +/// 32 CSPRNG bytes, base64url without padding — the plaintext secret half. +pub fn generate_secret() -> String { + use base64::Engine; + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// Compose the full token string handed to the device: +/// `QC1|||.|sha256:` +pub fn compose_token_string( + gateway: &str, + org_id: Uuid, + token_id: &str, + secret: &str, + ca_fingerprint_hex: &str, +) -> String { + format!("QC1|{gateway}|{org_id}|{token_id}.{secret}|sha256:{ca_fingerprint_hex}") +} + +#[derive(Deserialize)] +pub struct CreateTokenRequest { + label: Option, + /// Lifetime in hours; defaults to 24. + expires_hours: Option, + /// Maximum number of enrollments; None = unlimited. + max_uses: Option, +} + +/// POST /api/orgs/:organization_guid/enroll-tokens — owner/admin only. +/// The response's `token` field is shown exactly once. +pub async fn create( + State(state): State>, + Extension(claims): Extension, + Path(organization_guid): Path, + Json(body): Json, +) -> Result> { + let uid = caller_id(&claims)?; + require_manager(&state, organization_guid, uid).await?; + + let expires_hours = body.expires_hours.unwrap_or(DEFAULT_EXPIRES_HOURS); + if !(1..=MAX_EXPIRES_HOURS).contains(&expires_hours) { + return Err(AppError::BadRequest(format!( + "expires_hours must be between 1 and {MAX_EXPIRES_HOURS}" + ))); + } + if body.max_uses.is_some_and(|m| m < 1) { + return Err(AppError::BadRequest("max_uses must be at least 1".into())); + } + let label = body.label.as_deref().map(str::trim).filter(|l| !l.is_empty()); + + let token_id = generate_token_id(); + let secret = generate_secret(); + let secret_for_hash = secret.clone(); + let secret_hash = tokio::task::spawn_blocking(move || security::hash_password(&secret_for_hash)) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("hash task failed: {e}")))? + .map_err(AppError::Internal)?; + + let meta: EnrollmentTokenMeta = sqlx::query_as( + "INSERT INTO enrollment_tokens \ + (token_id, org_id, secret_hash, created_by, expires_at, max_uses, label) \ + VALUES ($1, $2, $3, $4, now() + make_interval(hours => $5), $6, $7) \ + RETURNING token_id, label, created_at, expires_at, max_uses, use_count, revoked_at, \ + (SELECT email FROM users WHERE id = created_by) AS created_by_email", + ) + .bind(&token_id) + .bind(organization_guid) + .bind(&secret_hash) + .bind(uid) + .bind(expires_hours as i32) + .bind(body.max_uses) + .bind(label) + .fetch_one(&state.db) + .await?; + + let token = compose_token_string( + &state.gateway_addr, + organization_guid, + &token_id, + &secret, + &state.gateway_ca_fingerprint_hex, + ); + + audit::record( + &state.db, + Some(organization_guid), + &format!("user:{uid}"), + "token.created", + json!({ "token_id": token_id, "label": label, + "expires_hours": expires_hours, "max_uses": body.max_uses }), + ) + .await; + tracing::info!(%token_id, org = %organization_guid, "enrollment token created"); + + let mut out = serde_json::to_value(&meta).map_err(|e| AppError::Internal(e.into()))?; + out["token"] = json!(token); // the one and only disclosure of the secret + Ok(Json(out)) +} + +/// GET /api/orgs/:organization_guid/enroll-tokens — metadata only, any member. +pub async fn list( + State(state): State>, + Extension(claims): Extension, + Path(organization_guid): Path, +) -> Result>> { + let uid = caller_id(&claims)?; + member_org(&state, organization_guid, uid).await?; + + let tokens = sqlx::query_as::<_, EnrollmentTokenMeta>( + "SELECT t.token_id, t.label, t.created_at, t.expires_at, t.max_uses, t.use_count, \ + t.revoked_at, u.email AS created_by_email \ + FROM enrollment_tokens t LEFT JOIN users u ON u.id = t.created_by \ + WHERE t.org_id = $1 ORDER BY t.created_at DESC", + ) + .bind(organization_guid) + .fetch_all(&state.db) + .await?; + + Ok(Json(tokens)) +} + +/// POST /api/orgs/:organization_guid/enroll-tokens/:token_id/revoke — +/// owner/admin only. Revocation is immediate and permanent. +pub async fn revoke( + State(state): State>, + Extension(claims): Extension, + Path((organization_guid, token_id)): Path<(Uuid, String)>, +) -> Result> { + let uid = caller_id(&claims)?; + require_manager(&state, organization_guid, uid).await?; + + let updated = sqlx::query( + "UPDATE enrollment_tokens SET revoked_at = now() \ + WHERE token_id = $1 AND org_id = $2 AND revoked_at IS NULL", + ) + .bind(&token_id) + .bind(organization_guid) + .execute(&state.db) + .await?; + if updated.rows_affected() == 0 { + return Err(AppError::NotFound("no such active token".into())); + } + + audit::record( + &state.db, + Some(organization_guid), + &format!("user:{uid}"), + "token.revoked", + json!({ "token_id": token_id }), + ) + .await; + tracing::info!(%token_id, org = %organization_guid, "enrollment token revoked"); + + Ok(Json(json!({ "ok": true }))) +} diff --git a/backend/src/console/mod.rs b/backend/src/console/mod.rs new file mode 100644 index 0000000..5a8ba9f --- /dev/null +++ b/backend/src/console/mod.rs @@ -0,0 +1,7 @@ +//! Cloud console (user realm): member auth and the org-scoped REST endpoints +//! behind it — organizations, device inventory, enrollment tokens. + +pub mod auth; +pub mod devices; +pub mod enroll_tokens; +pub mod organizations; diff --git a/backend/src/organizations.rs b/backend/src/console/organizations.rs similarity index 98% rename from backend/src/organizations.rs rename to backend/src/console/organizations.rs index 4ecfe6f..58d1240 100644 --- a/backend/src/organizations.rs +++ b/backend/src/console/organizations.rs @@ -22,7 +22,8 @@ fn caller_id(claims: &Claims) -> Result { /// The organization, but only if `uid` is a member — the membership check every /// sub-organization route hangs off. A non-member gets 403 (real tenant /// isolation on the server), never a leak of whether the org exists. -async fn member_org( +/// pub(crate): the enrollment-token and device routes gate on it too. +pub(crate) async fn member_org( state: &Arc, organization_guid: Uuid, uid: Uuid, diff --git a/backend/src/gateway/clone_detect.rs b/backend/src/gateway/clone_detect.rs new file mode 100644 index 0000000..6e9f893 --- /dev/null +++ b/backend/src/gateway/clone_detect.rs @@ -0,0 +1,147 @@ +//! First-pass clone detection: a device's private key should exist in exactly +//! one place, so the same device_id talking from two places at once — or +//! flapping between source IPs — suggests a cloned key. +//! +//! There is no long-lived control channel yet, so "an active session" is +//! approximated as "seen within the last 10 minutes". Every authenticated +//! device contact (today: certificate renewal; later: the control channel) +//! should be reported via [`CloneDetector::record`]; a returned signal means +//! the caller should raise a "Possible cloned device" event in the org. + +use std::collections::{HashMap, VecDeque}; +use std::net::IpAddr; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// How long a source IP counts as an "active session" after last contact. +const ACTIVE_WINDOW: Duration = Duration::from_secs(10 * 60); +/// Source-IP alternations within [`ACTIVE_WINDOW`] that trigger on their own. +const MAX_SWITCHES: usize = 3; +/// Minimum spacing between alerts for the same device (avoid event spam). +const ALERT_COOLDOWN: Duration = Duration::from_secs(10 * 60); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CloneSignal { + /// Contact from a new IP while the previous IP's session is still active. + ConcurrentSources { previous: IpAddr, current: IpAddr }, + /// Source IP alternated more than [`MAX_SWITCHES`] times in the window. + FlappingSources { switches: usize }, +} + +struct DeviceHistory { + last_ip: IpAddr, + last_seen: Instant, + switches: VecDeque, + last_alert: Option, +} + +#[derive(Default)] +pub struct CloneDetector { + inner: Mutex>, +} + +impl CloneDetector { + pub fn new() -> Self { + Self::default() + } + + /// Record an authenticated contact from `ip` for `device_id`; returns a + /// signal when the pattern looks like a cloned device (rate-limited to + /// one alert per device per cooldown). + pub fn record(&self, device_id: &str, ip: IpAddr) -> Option { + let now = Instant::now(); + let mut map = self.inner.lock().expect("clone detector lock"); + + let Some(hist) = map.get_mut(device_id) else { + map.insert( + device_id.to_string(), + DeviceHistory { + last_ip: ip, + last_seen: now, + switches: VecDeque::new(), + last_alert: None, + }, + ); + return None; + }; + + let previous_ip = hist.last_ip; + let previous_seen = hist.last_seen; + let switched = previous_ip != ip; + hist.last_ip = ip; + hist.last_seen = now; + + if switched { + hist.switches.push_back(now); + } + while hist + .switches + .front() + .is_some_and(|t| now - *t >= ACTIVE_WINDOW) + { + hist.switches.pop_front(); + } + + let signal = if switched && now - previous_seen < ACTIVE_WINDOW { + if hist.switches.len() > MAX_SWITCHES { + Some(CloneSignal::FlappingSources { + switches: hist.switches.len(), + }) + } else { + Some(CloneSignal::ConcurrentSources { + previous: previous_ip, + current: ip, + }) + } + } else { + None + }; + + // Apply the per-device alert cooldown. + if signal.is_some() { + if hist + .last_alert + .is_some_and(|t| now - t < ALERT_COOLDOWN) + { + return None; + } + hist.last_alert = Some(now); + } + signal + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn same_ip_never_signals() { + let d = CloneDetector::new(); + assert_eq!(d.record("QF-A", ip("1.1.1.1")), None); + assert_eq!(d.record("QF-A", ip("1.1.1.1")), None); + } + + #[test] + fn concurrent_source_signals_once_per_cooldown() { + let d = CloneDetector::new(); + assert_eq!(d.record("QF-A", ip("1.1.1.1")), None); + assert!(matches!( + d.record("QF-A", ip("2.2.2.2")), + Some(CloneSignal::ConcurrentSources { .. }) + )); + // Immediately flapping again is inside the alert cooldown. + assert_eq!(d.record("QF-A", ip("1.1.1.1")), None); + } + + #[test] + fn devices_are_independent() { + let d = CloneDetector::new(); + assert_eq!(d.record("QF-A", ip("1.1.1.1")), None); + assert_eq!(d.record("QF-B", ip("2.2.2.2")), None); + } +} diff --git a/backend/src/gateway/device.rs b/backend/src/gateway/device.rs new file mode 100644 index 0000000..5648581 --- /dev/null +++ b/backend/src/gateway/device.rs @@ -0,0 +1,162 @@ +//! `quartzcommand.device.v1.DeviceService` — the mTLS-authenticated device +//! surface. Identity comes exclusively from the presented client certificate +//! (already validated against the device CA by the TLS handshake); nothing in +//! the request body is trusted for identity. + +use std::net::IpAddr; +use std::sync::Arc; + +use serde_json::json; +use tonic::{Request, Response, Status}; + +use crate::audit; +use crate::gateway::clone_detect::CloneSignal; +use crate::gateway::pb::device::v1::{ + device_service_server::DeviceService, RenewCertificateRequest, RenewCertificateResponse, +}; +use crate::gateway::GrpcState; +use crate::pki::ca::{self, DeviceIdentity, DEVICE_CERT_DAYS}; + +pub struct DeviceGrpc { + pub state: Arc, +} + +fn internal(e: impl std::fmt::Display) -> Status { + tracing::error!("device service internal error: {e}"); + Status::internal("internal error") +} + +/// Renew the client certificate for an authenticated device identity. +/// Factored out of the tonic trait so tests can call it with a synthetic +/// identity (tonic requests can't carry peer certs outside a real handshake). +pub async fn renew_with_identity( + state: &GrpcState, + ident: &DeviceIdentity, + csr_der: &[u8], + source_ip: Option, +) -> Result { + let db = &state.db; + let ip_str = source_ip.map(|i| i.to_string()); + + // The device must still be adopted in the org its cert claims, with the + // same key. Uniform failure — a revoked device learns nothing extra. + let row: Option<(Vec, String)> = + sqlx::query_as("SELECT pubkey, state FROM devices WHERE device_id = $1 AND org_id = $2") + .bind(&ident.device_id) + .bind(ident.org_id) + .fetch_optional(db) + .await + .map_err(internal)?; + let authorized = row + .as_ref() + .is_some_and(|(pubkey, st)| st == "adopted" && *pubkey == ident.pubkey); + if !authorized { + audit::record( + db, + Some(ident.org_id), + &format!("device:{}", ident.device_id), + "cert.renewal_denied", + json!({ "device_id": ident.device_id, "source_ip": ip_str }), + ) + .await; + return Err(Status::permission_denied("renewal denied")); + } + + // Same validation as enrollment: CSR key must equal the device key and + // CN must equal the device id. + let issued = state + .device_ca + .issue_device_cert(csr_der, &ident.device_id, ident.org_id, &ident.pubkey) + .map_err(|e| { + tracing::warn!(device = %ident.device_id, "renewal CSR rejected: {e}"); + Status::invalid_argument("invalid CSR") + })?; + + sqlx::query( + "UPDATE devices SET cert_serial = $2, cert_not_after = $3, \ + last_seen_at = now(), last_seen_ip = COALESCE($4, last_seen_ip) \ + WHERE device_id = $1", + ) + .bind(&ident.device_id) + .bind(&issued.serial_hex) + .bind(issued.not_after) + .bind(&ip_str) + .execute(db) + .await + .map_err(internal)?; + + if let Some(ip) = source_ip { + report_contact(state, &ident.device_id, ident.org_id, ip).await; + } + + audit::record( + db, + Some(ident.org_id), + &format!("device:{}", ident.device_id), + "cert.renewed", + json!({ "device_id": ident.device_id, "serial": issued.serial_hex, + "not_after": issued.not_after.to_rfc3339(), "source_ip": ip_str }), + ) + .await; + + let not_after_unix = issued.not_after.timestamp(); + let lifetime_secs = DEVICE_CERT_DAYS * 24 * 3600; + Ok(RenewCertificateResponse { + client_cert_der: issued.cert_der, + ca_chain_der: state.device_ca.ca_chain_der(), + not_after_unix, + // Rotation is designed to happen at 2/3 of cert lifetime. + renew_after_unix: not_after_unix - lifetime_secs / 3, + }) +} + +/// Feed the clone detector with an authenticated device contact; raises a +/// "Possible cloned device" org event when the pattern warrants it. Also the +/// hook the future control channel should call on every device connection. +pub async fn report_contact(state: &GrpcState, device_id: &str, org_id: uuid::Uuid, ip: IpAddr) { + let Some(signal) = state.clone_detector.record(device_id, ip) else { + return; + }; + let details = match &signal { + CloneSignal::ConcurrentSources { previous, current } => json!({ + "device_id": device_id, "kind": "concurrent_sources", + "previous_ip": previous.to_string(), "current_ip": current.to_string(), + }), + CloneSignal::FlappingSources { switches } => json!({ + "device_id": device_id, "kind": "flapping_sources", "switches": switches, + }), + }; + tracing::warn!(device = %device_id, ?signal, "possible cloned device"); + audit::raise_event( + &state.db, + org_id, + "warning", + "Possible cloned device", + details, + ) + .await; +} + +#[tonic::async_trait] +impl DeviceService for DeviceGrpc { + async fn renew_certificate( + &self, + request: Request, + ) -> Result, Status> { + let source_ip = request.remote_addr().map(|a| a.ip()); + + // peer_certs() is populated by tonic's TLS layer once the client cert + // chain validated against the device CA root. Absent cert (or the + // plaintext dev listener) → unauthenticated. + let cert = request + .peer_certs() + .and_then(|certs| certs.first().cloned()) + .ok_or_else(|| Status::unauthenticated("client certificate required"))?; + let ident = ca::identity_from_cert_der(cert.as_ref()) + .map_err(|_| Status::unauthenticated("unrecognized client certificate"))?; + + let resp = + renew_with_identity(&self.state, &ident, &request.get_ref().csr_der, source_ip).await?; + Ok(Response::new(resp)) + } +} diff --git a/backend/src/gateway/enrollment.rs b/backend/src/gateway/enrollment.rs new file mode 100644 index 0000000..233ac9c --- /dev/null +++ b/backend/src/gateway/enrollment.rs @@ -0,0 +1,370 @@ +//! `quartzcommand.enrollment.v1.EnrollmentService` — the unauthenticated +//! bootstrap path a factory-fresh QuartzFire device uses to trade an +//! enrollment token + proof-of-possession of its Ed25519 key for an mTLS +//! client certificate. +//! +//! Error discipline mirrors the login endpoints: the device learns nothing +//! about *why* an attempt failed. BeginEnrollment answers a uniform +//! `NOT_FOUND` whether the token is unknown, expired, revoked, or exhausted; +//! CompleteEnrollment answers a uniform `PERMISSION_DENIED` for every +//! verification failure. The real reason goes to the audit log. + +use std::net::IpAddr; +use std::sync::Arc; + +use ed25519_dalek::{Signature, VerifyingKey}; +use rand::RngCore; +use serde_json::json; +use sqlx::PgPool; +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +use crate::gateway::pb::enrollment::v1::{ + enrollment_service_server::EnrollmentService, BeginEnrollmentRequest, BeginEnrollmentResponse, + CompleteEnrollmentRequest, CompleteEnrollmentResponse, +}; +use crate::gateway::GrpcState; +use crate::pki::deviceid; +use crate::{audit, security}; + +/// Enrollment sessions live 5 minutes. +const SESSION_TTL_MINUTES: i64 = 5; + +pub struct EnrollmentGrpc { + pub state: Arc, +} + +/// Uniform BeginEnrollment failure — never distinguishes unknown from +/// expired/revoked/exhausted tokens. +fn begin_fail() -> Status { + Status::not_found("enrollment token not found") +} + +/// Uniform CompleteEnrollment failure — one message for every rejection. +fn complete_fail() -> Status { + Status::permission_denied("enrollment failed") +} + +fn internal(e: impl std::fmt::Display) -> Status { + tracing::error!("enrollment internal error: {e}"); + Status::internal("internal error") +} + +#[derive(sqlx::FromRow)] +struct TokenRow { + org_id: Uuid, + secret_hash: String, + expires_at: chrono::DateTime, + max_uses: Option, + use_count: i32, + revoked_at: Option>, +} + +impl TokenRow { + fn usable(&self) -> Result<(), &'static str> { + if self.revoked_at.is_some() { + return Err("token_revoked"); + } + if self.expires_at <= chrono::Utc::now() { + return Err("token_expired"); + } + if self.max_uses.is_some_and(|m| self.use_count >= m) { + return Err("token_exhausted"); + } + Ok(()) + } +} + +async fn load_token(db: &PgPool, token_id: &str) -> Result, Status> { + sqlx::query_as::<_, TokenRow>( + "SELECT org_id, secret_hash, expires_at, max_uses, use_count, revoked_at \ + FROM enrollment_tokens WHERE token_id = $1", + ) + .bind(token_id) + .fetch_optional(db) + .await + .map_err(internal) +} + +impl EnrollmentGrpc { + fn source_ip(&self, req: &Request) -> Option { + req.remote_addr().map(|a| a.ip()) + } + + /// Rate-limit the bootstrap path aggressively per source IP. Requests + /// with no resolvable peer address (in-process tests) are not limited. + #[allow(clippy::result_large_err)] // tonic::Status is just big + fn check_rate(&self, req: &Request) -> Result<(), Status> { + if let Some(ip) = self.source_ip(req) { + if !self.state.enroll_limiter.check(ip) { + return Err(Status::resource_exhausted("rate limit exceeded")); + } + } + Ok(()) + } +} + +#[tonic::async_trait] +impl EnrollmentService for EnrollmentGrpc { + async fn begin_enrollment( + &self, + request: Request, + ) -> Result, Status> { + self.check_rate(&request)?; + let ip = self.source_ip(&request).map(|i| i.to_string()); + let req = request.into_inner(); + let db = &self.state.db; + + if req.device_pubkey.len() != deviceid::ED25519_PUBKEY_LEN { + return Err(Status::invalid_argument( + "device_pubkey must be a raw 32-byte Ed25519 public key", + )); + } + + // Opportunistic cleanup of expired sessions (no background job needed). + let _ = sqlx::query("DELETE FROM enrollment_sessions WHERE expires_at < now()") + .execute(db) + .await; + + let token = match load_token(db, &req.token_id).await? { + Some(t) => t, + None => { + audit::record( + db, + None, + "system", + "enrollment.failed", + json!({ "phase": "begin", "reason": "unknown_token", + "token_id": req.token_id, "source_ip": ip }), + ) + .await; + return Err(begin_fail()); + } + }; + if let Err(reason) = token.usable() { + audit::record( + db, + Some(token.org_id), + "system", + "enrollment.failed", + json!({ "phase": "begin", "reason": reason, + "token_id": req.token_id, "source_ip": ip }), + ) + .await; + return Err(begin_fail()); + } + + let mut nonce = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + + let (session_id,): (Uuid,) = sqlx::query_as( + "INSERT INTO enrollment_sessions (token_id, device_pubkey, nonce, expires_at) \ + VALUES ($1, $2, $3, now() + make_interval(mins => $4)) RETURNING id", + ) + .bind(&req.token_id) + .bind(&req.device_pubkey[..]) + .bind(&nonce[..]) + .bind(SESSION_TTL_MINUTES as i32) + .fetch_one(db) + .await + .map_err(internal)?; + + Ok(Response::new(BeginEnrollmentResponse { + nonce: nonce.to_vec(), + enrollment_session_id: session_id.to_string(), + })) + } + + async fn complete_enrollment( + &self, + request: Request, + ) -> Result, Status> { + self.check_rate(&request)?; + let ip = self.source_ip(&request).map(|i| i.to_string()); + let req = request.into_inner(); + let db = &self.state.db; + + // Audit + uniform failure in one place; the caller sees no reason. + macro_rules! reject { + ($org:expr, $reason:expr, $extra:tt) => {{ + let mut details = json!($extra); + details["reason"] = json!($reason); + details["phase"] = json!("complete"); + details["source_ip"] = json!(ip); + audit::record(db, $org, "system", "enrollment.failed", details).await; + return Err(complete_fail()); + }}; + } + + let session_id: Uuid = match req.enrollment_session_id.parse() { + Ok(id) => id, + Err(_) => reject!(None, "bad_session_id", {}), + }; + + // Claim the session atomically — each nonce is single-use even under + // concurrent completion attempts. + let session: Option<(String, Vec, Vec)> = sqlx::query_as( + "DELETE FROM enrollment_sessions WHERE id = $1 AND expires_at > now() \ + RETURNING token_id, device_pubkey, nonce", + ) + .bind(session_id) + .fetch_optional(db) + .await + .map_err(internal)?; + let Some((token_id, device_pubkey, nonce)) = session else { + reject!(None, "unknown_or_expired_session", {}); + }; + + // Verify the plaintext secret against the stored Argon2id hash. Same + // uniform-timing discipline as login: a missing token still burns a + // dummy verification. + let token = load_token(db, &token_id).await?; + let stored = token.as_ref().map(|t| t.secret_hash.clone()); + let secret = req.token_secret.clone(); + let secret_ok = + tokio::task::spawn_blocking(move || security::verify_password(&secret, stored.as_deref())) + .await + .map_err(internal)?; + let Some(token) = token else { + reject!(None, "unknown_token", { "token_id": token_id }); + }; + let org_id = token.org_id; + if !secret_ok { + reject!(Some(org_id), "bad_secret", { "token_id": token_id }); + } + if let Err(reason) = token.usable() { + reject!(Some(org_id), reason, { "token_id": token_id }); + } + + // The claimed device_id must be the canonical derivation of the key + // presented at BeginEnrollment. + let derived = deviceid::derive_device_id(&device_pubkey); + if derived != req.device_id { + reject!(Some(org_id), "device_id_mismatch", + { "claimed": req.device_id, "token_id": token_id }); + } + + // Proof-of-possession: Ed25519 signature over our nonce. + let pubkey_arr: [u8; 32] = match device_pubkey.as_slice().try_into() { + Ok(a) => a, + Err(_) => reject!(Some(org_id), "bad_pubkey", { "device_id": derived }), + }; + let sig_ok = VerifyingKey::from_bytes(&pubkey_arr) + .ok() + .zip(Signature::from_slice(&req.nonce_signature).ok()) + .is_some_and(|(vk, sig)| vk.verify_strict(&nonce, &sig).is_ok()); + if !sig_ok { + reject!(Some(org_id), "bad_signature", { "device_id": derived }); + } + + // CSR must carry the same key and CN=device_id; issue the cert (pure + // computation — nothing is persisted yet). + let issued = match self.state.device_ca.issue_device_cert( + &req.csr_der, + &derived, + org_id, + &device_pubkey, + ) { + Ok(c) => c, + Err(e) => { + reject!(Some(org_id), "bad_csr", + { "device_id": derived, "detail": e.to_string() }) + } + }; + + // Consume a token use and adopt the device atomically. The token row + // UPDATE serializes concurrent enrollments, so max_uses can never be + // oversubscribed by a race. + let mut tx = db.begin().await.map_err(internal)?; + + let consumed = sqlx::query( + "UPDATE enrollment_tokens SET use_count = use_count + 1 \ + WHERE token_id = $1 AND revoked_at IS NULL AND expires_at > now() \ + AND (max_uses IS NULL OR use_count < max_uses)", + ) + .bind(&token_id) + .execute(&mut *tx) + .await + .map_err(internal)?; + if consumed.rows_affected() == 0 { + drop(tx); + reject!(Some(org_id), "token_exhausted", { "token_id": token_id }); + } + + let existing: Option<(Uuid, String)> = + sqlx::query_as("SELECT org_id, state FROM devices WHERE device_id = $1 FOR UPDATE") + .bind(&derived) + .fetch_optional(&mut *tx) + .await + .map_err(internal)?; + match &existing { + Some((existing_org, _)) if *existing_org != org_id => { + drop(tx); + // Uniform error: a caller must not learn the id exists elsewhere. + reject!(Some(org_id), "device_in_other_org", { "device_id": derived }); + } + Some((_, state)) if state == "adopted" => { + drop(tx); + // Same key re-enrolling while adopted smells like a cloned + // key; require an explicit revoke first. + reject!(Some(org_id), "already_adopted", { "device_id": derived }); + } + _ => {} // new device, or same-org pending/revoked → (re-)adopt + } + + sqlx::query( + "INSERT INTO devices (device_id, org_id, pubkey, cert_serial, cert_not_after, state, \ + enrolled_at, enrolled_via_token, hostname, qf_version, \ + last_seen_at, last_seen_ip) \ + VALUES ($1, $2, $3, $4, $5, 'adopted', now(), $6, $7, $8, now(), $9) \ + ON CONFLICT (device_id) DO UPDATE SET \ + pubkey = EXCLUDED.pubkey, cert_serial = EXCLUDED.cert_serial, \ + cert_not_after = EXCLUDED.cert_not_after, state = 'adopted', \ + enrolled_at = now(), enrolled_via_token = EXCLUDED.enrolled_via_token, \ + hostname = EXCLUDED.hostname, qf_version = EXCLUDED.qf_version, \ + last_seen_at = now(), last_seen_ip = EXCLUDED.last_seen_ip", + ) + .bind(&derived) + .bind(org_id) + .bind(&device_pubkey[..]) + .bind(&issued.serial_hex) + .bind(issued.not_after) + .bind(&token_id) + .bind(&req.hostname) + .bind(&req.qf_version) + .bind(&ip) + .execute(&mut *tx) + .await + .map_err(internal)?; + + tx.commit().await.map_err(internal)?; + + audit::record( + db, + Some(org_id), + &format!("device:{derived}"), + "enrollment.succeeded", + json!({ "device_id": derived, "token_id": token_id, + "hostname": req.hostname, "qf_version": req.qf_version, + "source_ip": ip }), + ) + .await; + audit::record( + db, + Some(org_id), + "system", + "cert.issued", + json!({ "device_id": derived, "serial": issued.serial_hex, + "not_after": issued.not_after.to_rfc3339() }), + ) + .await; + tracing::info!(device = %derived, org = %org_id, "device enrolled"); + + Ok(Response::new(CompleteEnrollmentResponse { + client_cert_der: issued.cert_der, + ca_chain_der: self.state.device_ca.ca_chain_der(), + assigned_gateway: self.state.gateway_addr.clone(), + org_id: org_id.to_string(), + })) + } +} diff --git a/backend/src/gateway/mod.rs b/backend/src/gateway/mod.rs new file mode 100644 index 0000000..238e034 --- /dev/null +++ b/backend/src/gateway/mod.rs @@ -0,0 +1,99 @@ +//! Device gateway: shared state + server startup for the gRPC services. +//! +//! TLS model: when `QC_GRPC_TLS_CERT_FILE`/`QC_GRPC_TLS_KEY_FILE` are set the +//! listener terminates TLS itself, trusting the device CA for client certs +//! with client auth *optional* — EnrollmentService is the bootstrap path and +//! must work without a client cert, while DeviceService rejects any request +//! that didn't present one. Without the env vars the listener is plaintext +//! (local dev only; DeviceService is then effectively disabled). + +pub mod clone_detect; +pub mod device; +pub mod enrollment; +pub mod pb; +pub mod ratelimit; + +use anyhow::{Context, Result}; +use sqlx::PgPool; +use std::sync::Arc; +use std::time::Duration; + +use crate::config::Config; +use crate::pki::ca::DeviceCa; +use self::clone_detect::CloneDetector; +use self::device::DeviceGrpc; +use self::enrollment::EnrollmentGrpc; +use self::pb::device::v1::device_service_server::DeviceServiceServer; +use self::pb::enrollment::v1::enrollment_service_server::EnrollmentServiceServer; +use self::ratelimit::RateLimiter; + +/// Aggressive per-IP budget for the unauthenticated enrollment path: an +/// honest device needs 2 calls; 10/min absorbs retries without enabling +/// brute force. +const ENROLL_RATE_MAX: usize = 10; +const ENROLL_RATE_WINDOW: Duration = Duration::from_secs(60); + +pub struct GrpcState { + pub db: PgPool, + pub device_ca: Arc, + /// `host:port` devices should use for their control channel (and the + /// gateway field embedded in enrollment tokens). + pub gateway_addr: String, + pub enroll_limiter: RateLimiter, + pub clone_detector: CloneDetector, +} + +impl GrpcState { + pub fn new(db: PgPool, device_ca: Arc, gateway_addr: String) -> Self { + Self { + db, + device_ca, + gateway_addr, + enroll_limiter: RateLimiter::new(ENROLL_RATE_MAX, ENROLL_RATE_WINDOW), + clone_detector: CloneDetector::new(), + } + } +} + +/// Serve the device gateway until process exit. +pub async fn serve(state: Arc, config: &Config) -> Result<()> { + let addr = config + .grpc_listen + .parse() + .with_context(|| format!("invalid QC_GRPC_LISTEN {:?}", config.grpc_listen))?; + + let mut builder = tonic::transport::Server::builder(); + + match (&config.grpc_tls_cert_file, &config.grpc_tls_key_file) { + (Some(cert_path), Some(key_path)) => { + let cert = std::fs::read(cert_path) + .with_context(|| format!("reading {}", cert_path.display()))?; + let key = std::fs::read(key_path) + .with_context(|| format!("reading {}", key_path.display()))?; + let tls = tonic::transport::ServerTlsConfig::new() + .identity(tonic::transport::Identity::from_pem(cert, key)) + .client_ca_root(tonic::transport::Certificate::from_pem( + state.device_ca.ca_cert_pem(), + )) + .client_auth_optional(true); + builder = builder.tls_config(tls).context("configuring gateway TLS")?; + tracing::info!("device gateway listening on {addr} (TLS, optional client certs)"); + } + (None, None) => { + tracing::warn!( + "device gateway listening on {addr} WITHOUT TLS — dev only; \ + mTLS device services will reject all calls" + ); + } + _ => anyhow::bail!("QC_GRPC_TLS_CERT_FILE and QC_GRPC_TLS_KEY_FILE must be set together"), + } + + builder + .add_service(EnrollmentServiceServer::new(EnrollmentGrpc { + state: state.clone(), + })) + .add_service(DeviceServiceServer::new(DeviceGrpc { state })) + .serve(addr) + .await + .context("device gateway server") +} diff --git a/backend/src/gateway/pb.rs b/backend/src/gateway/pb.rs new file mode 100644 index 0000000..5c9f607 --- /dev/null +++ b/backend/src/gateway/pb.rs @@ -0,0 +1,14 @@ +//! Generated protobuf/tonic code for the device gateway (compiled by +//! `build.rs` from `proto/`). + +pub mod enrollment { + pub mod v1 { + tonic::include_proto!("quartzcommand.enrollment.v1"); + } +} + +pub mod device { + pub mod v1 { + tonic::include_proto!("quartzcommand.device.v1"); + } +} diff --git a/backend/src/gateway/ratelimit.rs b/backend/src/gateway/ratelimit.rs new file mode 100644 index 0000000..8f0fbc2 --- /dev/null +++ b/backend/src/gateway/ratelimit.rs @@ -0,0 +1,63 @@ +//! Per-source-IP rate limiting for the unauthenticated enrollment bootstrap +//! path. In-memory sliding window — the gateway is a single process, and the +//! goal is blunting brute-force of token ids/secrets, not precise QoS. + +use std::collections::{HashMap, VecDeque}; +use std::net::IpAddr; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +pub struct RateLimiter { + max_per_window: usize, + window: Duration, + inner: Mutex>>, +} + +impl RateLimiter { + pub fn new(max_per_window: usize, window: Duration) -> Self { + Self { + max_per_window, + window, + inner: Mutex::new(HashMap::new()), + } + } + + /// Record a hit from `ip`; returns false when the IP is over its budget. + pub fn check(&self, ip: IpAddr) -> bool { + let now = Instant::now(); + let mut map = self.inner.lock().expect("rate limiter lock"); + + // Keep the map from growing without bound under address churn. + if map.len() > 100_000 { + let window = self.window; + map.retain(|_, hits| hits.back().is_some_and(|t| now - *t < window)); + } + + let hits = map.entry(ip).or_default(); + while hits.front().is_some_and(|t| now - *t >= self.window) { + hits.pop_front(); + } + if hits.len() >= self.max_per_window { + return false; + } + hits.push_back(now); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn limits_per_ip() { + let rl = RateLimiter::new(3, Duration::from_secs(60)); + let a: IpAddr = "10.0.0.1".parse().unwrap(); + let b: IpAddr = "10.0.0.2".parse().unwrap(); + assert!(rl.check(a)); + assert!(rl.check(a)); + assert!(rl.check(a)); + assert!(!rl.check(a), "fourth hit in window must be rejected"); + assert!(rl.check(b), "other IPs are unaffected"); + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..08e48c7 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,43 @@ +//! Quartz Command backend library. `main.rs` is a thin binary over this; the +//! split exists so integration tests (`tests/`) can drive the enrollment +//! services directly. +//! +//! Layout: `admin/` and `console/` are the two REST realms, `gateway/` is the +//! device-facing gRPC surface, `pki/` the device CA. Cross-cutting modules +//! (config, db, error, models, security, audit, …) live at the root. + +pub mod admin; +pub mod audit; +pub mod config; +pub mod console; +pub mod db; +pub mod error; +pub mod gateway; +pub mod models; +pub mod pki; +pub mod security; +pub mod seed; +pub mod slug; + +use sqlx::PgPool; +use std::sync::Arc; + +use config::Config; +use pki::ca::DeviceCa; + +/// Shared state handed to every request handler. +pub struct AppState { + pub config: Config, + pub db: PgPool, + /// Secret used to sign **user** session JWTs. + pub jwt_secret: String, + /// Secret used to sign **admin** session JWTs (distinct realm). + pub admin_jwt_secret: String, + /// The internal CA issuing device mTLS client certs. + pub device_ca: Arc, + /// `host:port` embedded in enrollment tokens / returned to devices. + pub gateway_addr: String, + /// SHA-256 (hex) of the gateway's issuing CA cert — the `sha256:` field + /// of enrollment tokens. + pub gateway_ca_fingerprint_hex: String, +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 368af35..bec426e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,16 +1,3 @@ -mod admin_accounts; -mod admin_auth; -mod admin_orgs; -mod auth; -mod config; -mod db; -mod error; -mod models; -mod organizations; -mod security; -mod seed; -mod slug; - use anyhow::Result; use axum::{ middleware, @@ -21,17 +8,13 @@ use sqlx::PgPool; use std::sync::Arc; use tower_http::trace::TraceLayer; -use config::Config; - -/// Shared state handed to every request handler. -pub struct AppState { - pub config: Config, - pub db: PgPool, - /// Secret used to sign **user** session JWTs. - pub jwt_secret: String, - /// Secret used to sign **admin** session JWTs (distinct realm). - pub admin_jwt_secret: String, -} +use quartz_command::{ + admin, console, + config::Config, + db, gateway, + pki::ca::{self as device_ca, DeviceCa}, + security, seed, AppState, +}; #[tokio::main] async fn main() -> Result<()> { @@ -68,70 +51,107 @@ async fn main() -> Result<()> { let admin_jwt_secret = security::load_or_create_secret(&config.admin_jwt_secret_file); let listen = config.listen.clone(); + // Device PKI: load (or mint) the internal CA and note the fingerprint + // that goes into enrollment tokens. + let device_ca = Arc::new(DeviceCa::load_or_create(&config.device_ca_dir)?); + let gateway_ca_fingerprint_hex = + device_ca::gateway_ca_fingerprint_hex(config.gateway_ca_file.as_deref(), &device_ca)?; + let state = Arc::new(AppState { - config, - db: pool, + gateway_addr: config.gateway_addr.clone(), + gateway_ca_fingerprint_hex, + device_ca: device_ca.clone(), + db: pool.clone(), jwt_secret, admin_jwt_secret, + config, }); + // Device gateway (gRPC): enrollment bootstrap + mTLS device services. + let grpc_state = Arc::new(gateway::GrpcState::new( + pool.clone(), + device_ca, + state.config.gateway_addr.clone(), + )); + { + let grpc_config = state.config.clone(); + tokio::spawn(async move { + if let Err(e) = gateway::serve(grpc_state, &grpc_config).await { + tracing::error!("device gateway failed: {e:#}"); + } + }); + } + // Protected user routes: require a valid `qc_session`. let user_protected = Router::new() - .route("/api/auth/me", get(auth::me)) - .route("/api/orgs", get(organizations::list)) - .route("/api/orgs/:organization_guid", get(organizations::get_one)) + .route("/api/auth/me", get(console::auth::me)) + .route("/api/orgs", get(console::organizations::list)) + .route("/api/orgs/:organization_guid", get(console::organizations::get_one)) .route( "/api/orgs/:organization_guid/subs", - get(organizations::list_subs).post(organizations::create_sub), + get(console::organizations::list_subs).post(console::organizations::create_sub), ) .route( "/api/orgs/:organization_guid/subs/:sub_guid", - get(organizations::get_sub), + get(console::organizations::get_sub), + ) + .route( + "/api/orgs/:organization_guid/enroll-tokens", + get(console::enroll_tokens::list).post(console::enroll_tokens::create), + ) + .route( + "/api/orgs/:organization_guid/enroll-tokens/:token_id/revoke", + post(console::enroll_tokens::revoke), + ) + .route("/api/orgs/:organization_guid/devices", get(console::devices::list)) + .route( + "/api/orgs/:organization_guid/devices/:device_id/revoke", + post(console::devices::revoke), ) .layer(middleware::from_fn_with_state( state.clone(), - auth::require_auth, + console::auth::require_auth, )); // Protected admin routes: require a valid `qc_admin_session`. let admin_protected = Router::new() - .route("/api/admin/auth/me", get(admin_auth::me)) - .route("/api/admin/overview", get(admin_orgs::overview)) + .route("/api/admin/auth/me", get(admin::auth::me)) + .route("/api/admin/overview", get(admin::orgs::overview)) .route( "/api/admin/admins", - get(admin_accounts::list).post(admin_accounts::create), + get(admin::accounts::list).post(admin::accounts::create), ) .route( "/api/admin/admins/:admin_id", - delete(admin_accounts::delete).patch(admin_accounts::update), + delete(admin::accounts::delete).patch(admin::accounts::update), ) - .route("/api/admin/orgs", get(admin_orgs::list).post(admin_orgs::create)) + .route("/api/admin/orgs", get(admin::orgs::list).post(admin::orgs::create)) .route( "/api/admin/orgs/:organization_guid", - get(admin_orgs::get_one) - .patch(admin_orgs::update) - .delete(admin_orgs::delete), + get(admin::orgs::get_one) + .patch(admin::orgs::update) + .delete(admin::orgs::delete), ) .route( "/api/admin/orgs/:organization_guid/members", - post(admin_orgs::add_member), + post(admin::orgs::add_member), ) .route( "/api/admin/orgs/:organization_guid/members/:user_id", - delete(admin_orgs::remove_member).patch(admin_orgs::update_member), + delete(admin::orgs::remove_member).patch(admin::orgs::update_member), ) .layer(middleware::from_fn_with_state( state.clone(), - admin_auth::require_admin, + admin::auth::require_admin, )); // Public routes (login/logout for both realms) + health. let public = Router::new() .route("/api/health", get(|| async { "ok" })) - .route("/api/auth/login", post(auth::login)) - .route("/api/auth/logout", post(auth::logout)) - .route("/api/admin/auth/login", post(admin_auth::login)) - .route("/api/admin/auth/logout", post(admin_auth::logout)); + .route("/api/auth/login", post(console::auth::login)) + .route("/api/auth/logout", post(console::auth::logout)) + .route("/api/admin/auth/login", post(admin::auth::login)) + .route("/api/admin/auth/logout", post(admin::auth::logout)); let app = Router::new() .merge(public) diff --git a/backend/src/models.rs b/backend/src/models.rs index a86dad0..39fc21a 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -80,6 +80,37 @@ pub struct MemberOrganization { pub created_at: DateTime, } +/// Enrollment-token metadata as listed in the console. Never carries the +/// secret — only its Argon2id hash exists server-side, and the full token +/// string is disclosed exactly once at creation. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EnrollmentTokenMeta { + pub token_id: String, + pub label: Option, + pub created_at: DateTime, + pub expires_at: DateTime, + pub max_uses: Option, + pub use_count: i32, + pub revoked_at: Option>, + pub created_by_email: Option, +} + +/// An enrolled QuartzFire device as shown in the Inventory section. The raw +/// public key stays server-side; the device_id already commits to it. +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Device { + pub device_id: String, + pub state: String, + pub hostname: Option, + pub qf_version: Option, + pub cert_serial: Option, + pub cert_not_after: Option>, + pub enrolled_at: Option>, + pub enrolled_via_token: Option, + pub last_seen_at: Option>, + pub last_seen_ip: Option, +} + /// A sub-organization nested under a parent organization (cloud console's /// Organization Manager). Access derives from membership in the parent, so /// there is no per-caller role here. diff --git a/backend/src/pki/ca.rs b/backend/src/pki/ca.rs new file mode 100644 index 0000000..9e703d2 --- /dev/null +++ b/backend/src/pki/ca.rs @@ -0,0 +1,302 @@ +//! Internal device CA — issues the mTLS client certificates QuartzFire +//! devices receive at enrollment. Deliberately separate from anything +//! web-facing: one CA, with the owning organization embedded in each cert as +//! a SAN URI (`quartz://org//device/`). +//! +//! The CA key+cert live on disk next to the JWT secrets and are generated on +//! first start (same pattern as `security::load_or_create_secret`). The +//! stored DER is what devices get in `ca_chain_der`; reloading rebuilds an +//! rcgen signer from it, so the distributed CA cert stays byte-stable across +//! restarts. + +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, TimeZone, Utc}; +use rcgen::{ + BasicConstraints, CertificateParams, CertificateSigningRequestParams, DistinguishedName, + DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType, SerialNumber, +}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use uuid::Uuid; +use x509_parser::prelude::*; + +/// Client certs live 30 days; devices renew at 2/3 lifetime (20 days). +pub const DEVICE_CERT_DAYS: i64 = 30; + +/// OID of the Ed25519 signature/key algorithm (RFC 8410). +const OID_ED25519: &str = "1.3.101.112"; + +pub struct DeviceCa { + /// The CA certificate exactly as persisted — distributed to devices. + ca_cert_der: Vec, + ca_cert_pem: String, + /// Signer reconstructed from the stored cert + key (used only to sign). + issuer: rcgen::Certificate, + key: KeyPair, +} + +/// A freshly issued device client certificate. +pub struct IssuedCert { + pub cert_der: Vec, + pub serial_hex: String, + pub not_after: DateTime, +} + +impl DeviceCa { + /// Load the CA from `dir`, generating and persisting a new one on first + /// start. Fails (rather than silently regenerating) if the files exist + /// but cannot be parsed — regenerating would orphan every issued cert. + pub fn load_or_create(dir: &Path) -> Result { + let key_path = dir.join("device-ca-key.pem"); + let cert_path = dir.join("device-ca-cert.der"); + + if key_path.exists() && cert_path.exists() { + let key_pem = std::fs::read_to_string(&key_path) + .with_context(|| format!("reading {}", key_path.display()))?; + let key = KeyPair::from_pem(&key_pem).map_err(|e| anyhow!("parsing CA key: {e}"))?; + let ca_cert_der = std::fs::read(&cert_path) + .with_context(|| format!("reading {}", cert_path.display()))?; + let params = + CertificateParams::from_ca_cert_der(&ca_cert_der.clone().into()) + .map_err(|e| anyhow!("parsing stored CA cert: {e}"))?; + // Re-signing yields fresh signature bytes, but the issuer object is + // only used to sign leaves (DN/SKI/key are what matter); devices + // always receive the stored `ca_cert_der`. + let issuer = params + .self_signed(&key) + .map_err(|e| anyhow!("rebuilding CA signer: {e}"))?; + return Ok(Self { + ca_cert_pem: pem_encode_cert(&ca_cert_der), + ca_cert_der, + issuer, + key, + }); + } + + let key = KeyPair::generate().map_err(|e| anyhow!("generating CA key: {e}"))?; + let mut params = CertificateParams::default(); + let mut dn = DistinguishedName::new(); + dn.push(DnType::OrganizationName, "Quartz Command"); + dn.push(DnType::CommonName, "Quartz Command Device CA"); + params.distinguished_name = dn; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.serial_number = Some(random_serial()); + let now = ::time::OffsetDateTime::now_utc(); + params.not_before = now - ::time::Duration::minutes(5); + params.not_after = now + ::time::Duration::days(3650); + + let cert = params + .self_signed(&key) + .map_err(|e| anyhow!("self-signing CA cert: {e}"))?; + let ca_cert_der = cert.der().to_vec(); + + std::fs::create_dir_all(dir)?; + std::fs::write(&cert_path, &ca_cert_der)?; + std::fs::write(&key_path, key.serialize_pem())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)); + } + tracing::info!("generated new device CA in {}", dir.display()); + + Ok(Self { + ca_cert_pem: pem_encode_cert(&ca_cert_der), + ca_cert_der, + issuer: cert, + key, + }) + } + + /// The CA chain devices receive alongside their client cert. + pub fn ca_chain_der(&self) -> Vec> { + vec![self.ca_cert_der.clone()] + } + + /// PEM form of the CA cert (tonic wants PEM for the client-cert root). + pub fn ca_cert_pem(&self) -> &str { + &self.ca_cert_pem + } + + /// SHA-256 fingerprint of the CA cert DER, lowercase hex. + pub fn fingerprint_hex(&self) -> String { + hex(&Sha256::digest(&self.ca_cert_der)) + } + + /// Validate a device CSR and issue a 30-day client certificate. + /// + /// Checks (all must hold): the CSR is well-formed with a valid + /// self-signature, its key is Ed25519 and equals `expected_pubkey`, and + /// its subject CN equals `device_id`. Cert contents (validity, serial, + /// SAN URI, usages) are set here — nothing from the CSR is trusted + /// beyond the public key. + pub fn issue_device_cert( + &self, + csr_der: &[u8], + device_id: &str, + org_id: Uuid, + expected_pubkey: &[u8], + ) -> Result { + let (_, csr) = X509CertificationRequest::from_der(csr_der) + .map_err(|e| anyhow!("malformed CSR: {e}"))?; + csr.verify_signature() + .map_err(|e| anyhow!("CSR signature invalid: {e}"))?; + + let info = &csr.certification_request_info; + let alg = info + .subject_pki + .algorithm + .algorithm + .to_id_string(); + if alg != OID_ED25519 { + bail!("CSR key is not Ed25519 (algorithm {alg})"); + } + if info.subject_pki.subject_public_key.data.as_ref() != expected_pubkey { + bail!("CSR public key does not match the enrolled device key"); + } + let cn = info + .subject + .iter_common_name() + .next() + .and_then(|c| c.as_str().ok()) + .ok_or_else(|| anyhow!("CSR has no CN"))?; + if cn != device_id { + bail!("CSR CN {cn:?} does not match device id {device_id:?}"); + } + + let mut csr_params = CertificateSigningRequestParams::from_der(&csr_der.to_vec().into()) + .map_err(|e| anyhow!("unsupported CSR: {e}"))?; + + let serial = random_serial(); + let serial_hex = hex(serial.as_ref()); + let now = ::time::OffsetDateTime::now_utc(); + let not_after = now + ::time::Duration::days(DEVICE_CERT_DAYS); + + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, device_id); + csr_params.params.distinguished_name = dn; + csr_params.params.serial_number = Some(serial); + csr_params.params.not_before = now - ::time::Duration::minutes(5); + csr_params.params.not_after = not_after; + csr_params.params.is_ca = IsCa::ExplicitNoCa; + csr_params.params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + csr_params.params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + csr_params.params.subject_alt_names = vec![SanType::URI( + rcgen::Ia5String::try_from(format!("quartz://org/{org_id}/device/{device_id}")) + .map_err(|e| anyhow!("building SAN URI: {e}"))?, + )]; + + let cert = csr_params + .signed_by(&self.issuer, &self.key) + .map_err(|e| anyhow!("signing device cert: {e}"))?; + + Ok(IssuedCert { + cert_der: cert.der().to_vec(), + serial_hex, + not_after: Utc + .timestamp_opt(not_after.unix_timestamp(), 0) + .single() + .ok_or_else(|| anyhow!("cert expiry out of range"))?, + }) + } +} + +/// Identity asserted by a presented device client certificate. +#[derive(Debug, Clone)] +pub struct DeviceIdentity { + pub device_id: String, + pub org_id: Uuid, + pub pubkey: Vec, +} + +/// Parse a presented client certificate (DER) into the device identity it +/// asserts: CN = device_id, SAN URI carries the org, SPKI is the Ed25519 key. +/// Trust in the cert itself comes from the TLS handshake (client CA root). +pub fn identity_from_cert_der(cert_der: &[u8]) -> Result { + let (_, cert) = X509Certificate::from_der(cert_der) + .map_err(|e| anyhow!("malformed client cert: {e}"))?; + let device_id = cert + .subject() + .iter_common_name() + .next() + .and_then(|c| c.as_str().ok()) + .ok_or_else(|| anyhow!("client cert has no CN"))? + .to_string(); + let pubkey = cert + .public_key() + .subject_public_key + .data + .as_ref() + .to_vec(); + + let mut org_id = None; + for ext in cert.extensions() { + if let ParsedExtension::SubjectAlternativeName(san) = ext.parsed_extension() { + for name in &san.general_names { + if let GeneralName::URI(uri) = name { + if let Some(rest) = uri.strip_prefix("quartz://org/") { + if let Some((org, _)) = rest.split_once("/device/") { + org_id = org.parse::().ok(); + } + } + } + } + } + } + let org_id = org_id.ok_or_else(|| anyhow!("client cert has no quartz org SAN"))?; + + Ok(DeviceIdentity { + device_id, + org_id, + pubkey, + }) +} + +/// SHA-256 hex fingerprint for the `sha256:` field of enrollment tokens: the +/// CA that issued the gateway's TLS cert when configured (PEM or DER file), +/// otherwise the device CA cert — correct for self-hosted setups where the +/// gateway serves a cert from the internal CA, and harmless for the hosted +/// service where devices prefer WebPKI validation anyway. +pub fn gateway_ca_fingerprint_hex( + gateway_ca_file: Option<&Path>, + device_ca: &DeviceCa, +) -> Result { + let Some(path) = gateway_ca_file else { + return Ok(device_ca.fingerprint_hex()); + }; + let bytes = + std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; + let der = if bytes.starts_with(b"-----BEGIN") { + x509_parser::pem::parse_x509_pem(&bytes) + .map_err(|e| anyhow!("parsing PEM {}: {e}", path.display()))? + .1 + .contents + } else { + bytes + }; + Ok(hex(&Sha256::digest(&der))) +} + +/// Positive random 16-byte certificate serial. +fn random_serial() -> SerialNumber { + let mut bytes: [u8; 16] = rand::random(); + bytes[0] &= 0x7f; // keep the INTEGER positive + SerialNumber::from(bytes.to_vec()) +} + +fn hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect() +} + +fn pem_encode_cert(der: &[u8]) -> String { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + let mut out = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + out.push_str(std::str::from_utf8(chunk).expect("base64 is ascii")); + out.push('\n'); + } + out.push_str("-----END CERTIFICATE-----\n"); + out +} diff --git a/backend/src/pki/deviceid.rs b/backend/src/pki/deviceid.rs new file mode 100644 index 0000000..780bf28 --- /dev/null +++ b/backend/src/pki/deviceid.rs @@ -0,0 +1,81 @@ +//! QuartzFire device identity derivation. +//! +//! A device's ID is a pure function of its Ed25519 public key: +//! `"QF-" + Crockford base32(SHA256(pubkey_raw))[0:16]`, formatted in groups +//! of four (e.g. `QF-A1B2-C3D4-E5F6-G7H8`). The firmware derives the same +//! string on-device; enrollment verifies the device's claim against this. + +use sha2::{Digest, Sha256}; + +/// Crockford base32 alphabet (no I, L, O, U). +const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// Raw Ed25519 public keys are exactly 32 bytes. +pub const ED25519_PUBKEY_LEN: usize = 32; + +/// Standard MSB-first base32 over the Crockford alphabet, `n` output chars. +fn crockford_prefix(data: &[u8], n: usize) -> String { + let mut out = String::with_capacity(n); + let mut acc: u32 = 0; + let mut bits: u32 = 0; + for &b in data { + acc = (acc << 8) | b as u32; + bits += 8; + while bits >= 5 { + bits -= 5; + out.push(CROCKFORD[((acc >> bits) & 0x1f) as usize] as char); + if out.len() == n { + return out; + } + } + } + // Zero-pad any trailing partial group (standard base32 behavior; never + // reached for the 16-char prefix of a 32-byte digest). + if bits > 0 && out.len() < n { + out.push(CROCKFORD[((acc << (5 - bits)) & 0x1f) as usize] as char); + } + out +} + +/// Derive the canonical device ID from a raw 32-byte Ed25519 public key. +pub fn derive_device_id(pubkey_raw: &[u8]) -> String { + let digest = Sha256::digest(pubkey_raw); + let chars = crockford_prefix(&digest, 16); + let groups: Vec<&str> = chars + .as_bytes() + .chunks(4) + .map(|c| std::str::from_utf8(c).expect("ascii")) + .collect(); + format!("QF-{}", groups.join("-")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derivation_shape() { + let id = derive_device_id(&[0u8; 32]); + assert!(id.starts_with("QF-")); + assert_eq!(id.len(), 3 + 16 + 3); // QF- + 16 chars + 3 inner dashes + assert_eq!(id.split('-').count(), 5); + // No excluded Crockford letters. + for c in id.chars() { + assert!(!"ILOU".contains(c), "invalid char {c} in {id}"); + } + } + + #[test] + fn derivation_is_stable_and_key_dependent() { + let a = derive_device_id(&[1u8; 32]); + assert_eq!(a, derive_device_id(&[1u8; 32])); + assert_ne!(a, derive_device_id(&[2u8; 32])); + } + + #[test] + fn crockford_known_vector() { + // 0xFF -> bits 11111 111(00) -> "Z" then "W" (11100). + assert_eq!(crockford_prefix(&[0xff], 2), "ZW"); + assert_eq!(crockford_prefix(&[0x00], 1), "0"); + } +} diff --git a/backend/src/pki/mod.rs b/backend/src/pki/mod.rs new file mode 100644 index 0000000..7bbed65 --- /dev/null +++ b/backend/src/pki/mod.rs @@ -0,0 +1,5 @@ +//! Device PKI: the internal CA issuing device mTLS client certificates, and +//! the pubkey → device-ID derivation both sides of enrollment agree on. + +pub mod ca; +pub mod deviceid; diff --git a/backend/tests/enrollment.rs b/backend/tests/enrollment.rs new file mode 100644 index 0000000..d5e0294 --- /dev/null +++ b/backend/tests/enrollment.rs @@ -0,0 +1,602 @@ +//! Integration tests for the device enrollment token system: token lifecycle, +//! the full happy-path enrollment with real Ed25519 keys and CSRs, every +//! rejection path, cert issuance fields, renewal, and clone detection. +//! +//! Each test gets its own throwaway database via `#[sqlx::test]` (requires +//! DATABASE_URL pointing at a local PostgreSQL with createdb rights). + +use std::net::IpAddr; +use std::sync::Arc; + +use ed25519_dalek::pkcs8::EncodePrivateKey; +use ed25519_dalek::{Signer, SigningKey}; +use sqlx::PgPool; +use tonic::{Code, Request}; +use uuid::Uuid; + +use quartz_command::console::enroll_tokens::{ + compose_token_string, generate_secret, generate_token_id, +}; +use quartz_command::gateway::device::{renew_with_identity, report_contact}; +use quartz_command::gateway::enrollment::EnrollmentGrpc; +use quartz_command::gateway::pb::enrollment::v1::enrollment_service_server::EnrollmentService; +use quartz_command::gateway::pb::enrollment::v1::{ + BeginEnrollmentRequest, BeginEnrollmentResponse, CompleteEnrollmentRequest, + CompleteEnrollmentResponse, +}; +use quartz_command::gateway::GrpcState; +use quartz_command::pki::ca::{DeviceCa, DeviceIdentity, DEVICE_CERT_DAYS}; +use quartz_command::pki::deviceid::derive_device_id; +use quartz_command::security; + +// ── shared fixtures ───────────────────────────────────────────────────────── + +fn service(pool: &PgPool) -> EnrollmentGrpc { + let ca_dir = std::env::temp_dir().join(format!("qc-test-ca-{}", Uuid::new_v4())); + let ca = Arc::new(DeviceCa::load_or_create(&ca_dir).expect("test CA")); + EnrollmentGrpc { + state: Arc::new(GrpcState::new(pool.clone(), ca, "gw.test:8443".into())), + } +} + +async fn create_org(pool: &PgPool) -> Uuid { + let (id,): (Uuid,) = + sqlx::query_as("INSERT INTO organizations (name, slug) VALUES ('Test Org', $1) RETURNING id") + .bind(format!("test-{}", &Uuid::new_v4().to_string()[..8])) + .fetch_one(pool) + .await + .expect("create org"); + id +} + +/// Insert a token row directly (the REST handler is a thin wrapper over the +/// same statement) and return `(token_id, plaintext_secret)`. +async fn create_token( + pool: &PgPool, + org: Uuid, + expires_hours: i32, + max_uses: Option, +) -> (String, String) { + let token_id = generate_token_id(); + let secret = generate_secret(); + let hash = security::hash_password(&secret).expect("hash"); + sqlx::query( + "INSERT INTO enrollment_tokens (token_id, org_id, secret_hash, expires_at, max_uses) \ + VALUES ($1, $2, $3, now() + make_interval(hours => $4), $5)", + ) + .bind(&token_id) + .bind(org) + .bind(&hash) + .bind(expires_hours) + .bind(max_uses) + .execute(pool) + .await + .expect("insert token"); + (token_id, secret) +} + +struct Device { + key: SigningKey, + pubkey: Vec, + device_id: String, +} + +fn new_device() -> Device { + let key = SigningKey::generate(&mut rand::rngs::OsRng); + let pubkey = key.verifying_key().to_bytes().to_vec(); + let device_id = derive_device_id(&pubkey); + Device { + key, + pubkey, + device_id, + } +} + +/// Build a real Ed25519 CSR with the given CN, signed by `key`. +fn make_csr(key: &SigningKey, cn: &str) -> Vec { + let pkcs8 = key.to_pkcs8_der().expect("pkcs8"); + let rc_key = rcgen::KeyPair::try_from(pkcs8.as_bytes()).expect("rcgen key"); + let mut params = rcgen::CertificateParams::default(); + let mut dn = rcgen::DistinguishedName::new(); + dn.push(rcgen::DnType::CommonName, cn); + params.distinguished_name = dn; + params + .serialize_request(&rc_key) + .expect("csr") + .der() + .to_vec() +} + +async fn begin( + svc: &EnrollmentGrpc, + token_id: &str, + pubkey: &[u8], +) -> Result { + svc.begin_enrollment(Request::new(BeginEnrollmentRequest { + token_id: token_id.to_string(), + device_pubkey: pubkey.to_vec(), + })) + .await + .map(|r| r.into_inner()) +} + +fn complete_request( + session: &BeginEnrollmentResponse, + secret: &str, + dev: &Device, +) -> CompleteEnrollmentRequest { + CompleteEnrollmentRequest { + enrollment_session_id: session.enrollment_session_id.clone(), + token_secret: secret.to_string(), + device_id: dev.device_id.clone(), + nonce_signature: dev.key.sign(&session.nonce).to_bytes().to_vec(), + csr_der: make_csr(&dev.key, &dev.device_id), + hostname: "fw-test".into(), + qf_version: "1.2.3".into(), + } +} + +async fn complete( + svc: &EnrollmentGrpc, + req: CompleteEnrollmentRequest, +) -> Result { + svc.complete_enrollment(Request::new(req)) + .await + .map(|r| r.into_inner()) +} + +/// Begin + complete with everything valid. +async fn enroll( + svc: &EnrollmentGrpc, + token_id: &str, + secret: &str, + dev: &Device, +) -> Result { + let session = begin(svc, token_id, &dev.pubkey).await?; + complete(svc, complete_request(&session, secret, dev)).await +} + +/// The uniform CompleteEnrollment rejection every failure path must produce. +fn assert_uniform_rejection(status: &tonic::Status) { + assert_eq!(status.code(), Code::PermissionDenied); + assert_eq!(status.message(), "enrollment failed"); +} + +async fn device_state(pool: &PgPool, device_id: &str) -> Option<(Uuid, String)> { + sqlx::query_as("SELECT org_id, state FROM devices WHERE device_id = $1") + .bind(device_id) + .fetch_optional(pool) + .await + .expect("device query") +} + +async fn audit_actions(pool: &PgPool) -> Vec { + sqlx::query_scalar("SELECT action FROM audit_log ORDER BY created_at") + .fetch_all(pool) + .await + .expect("audit query") +} + +// ── happy path + cert fields ──────────────────────────────────────────────── + +#[sqlx::test(migrations = "./migrations")] +async fn happy_path_enrollment_issues_valid_cert(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, Some(5)).await; + let dev = new_device(); + + let resp = enroll(&svc, &token_id, &secret, &dev).await.expect("enrollment"); + + assert_eq!(resp.org_id, org.to_string()); + assert_eq!(resp.assigned_gateway, "gw.test:8443"); + assert_eq!(resp.ca_chain_der.len(), 1); + + // The issued cert: CN=device_id, org SAN URI, ~30-day validity, and the + // serial recorded on the device row. + let (_, cert) = + x509_parser::parse_x509_certificate(&resp.client_cert_der).expect("parse cert"); + let cn = cert + .subject() + .iter_common_name() + .next() + .and_then(|c| c.as_str().ok()) + .expect("cert CN"); + assert_eq!(cn, dev.device_id); + + let sans: Vec = cert + .extensions() + .iter() + .filter_map(|e| match e.parsed_extension() { + x509_parser::extensions::ParsedExtension::SubjectAlternativeName(san) => Some(san), + _ => None, + }) + .flat_map(|san| san.general_names.iter()) + .filter_map(|n| match n { + x509_parser::extensions::GeneralName::URI(u) => Some(u.to_string()), + _ => None, + }) + .collect(); + assert_eq!( + sans, + vec![format!("quartz://org/{org}/device/{}", dev.device_id)] + ); + + let lifetime = cert.validity().not_after.timestamp() - cert.validity().not_before.timestamp(); + let expected = DEVICE_CERT_DAYS * 24 * 3600; + assert!( + (lifetime - expected).abs() < 3600, + "cert lifetime {lifetime}s should be ~{expected}s" + ); + + let (dev_org, state) = device_state(&pool, &dev.device_id).await.expect("device row"); + assert_eq!(dev_org, org); + assert_eq!(state, "adopted"); + + let (serial, hostname, qf_version): (Option, Option, Option) = + sqlx::query_as( + "SELECT cert_serial, hostname, qf_version FROM devices WHERE device_id = $1", + ) + .bind(&dev.device_id) + .fetch_one(&pool) + .await + .expect("device fields"); + assert_eq!(hostname.as_deref(), Some("fw-test")); + assert_eq!(qf_version.as_deref(), Some("1.2.3")); + let cert_serial_hex = cert.serial.to_str_radix(16); + assert_eq!( + serial.expect("serial recorded").trim_start_matches('0'), + cert_serial_hex.trim_start_matches('0') + ); + + let (use_count,): (i32,) = + sqlx::query_as("SELECT use_count FROM enrollment_tokens WHERE token_id = $1") + .bind(&token_id) + .fetch_one(&pool) + .await + .expect("token row"); + assert_eq!(use_count, 1); + + let actions = audit_actions(&pool).await; + assert!(actions.contains(&"enrollment.succeeded".to_string())); + assert!(actions.contains(&"cert.issued".to_string())); +} + +#[sqlx::test(migrations = "./migrations")] +async fn token_string_format(pool: PgPool) { + let _ = pool; // format check only + let org: Uuid = "6dfe64c8-9edb-4f5c-8d1a-51f3e2f5c111".parse().unwrap(); + let token_id = generate_token_id(); + let secret = generate_secret(); + assert_eq!(token_id.len(), "tok_".len() + 12); + assert!(token_id.starts_with("tok_")); + assert_eq!(secret.len(), 43); // 32 bytes base64url, no padding + let s = compose_token_string("gw.example.com:8443", org, &token_id, &secret, "ab12"); + let parts: Vec<&str> = s.split('|').collect(); + assert_eq!(parts[0], "QC1"); + assert_eq!(parts[1], "gw.example.com:8443"); + assert_eq!(parts[2], org.to_string()); + assert_eq!(parts[3], format!("{token_id}.{secret}")); + assert_eq!(parts[4], "sha256:ab12"); +} + +// ── token lifecycle ───────────────────────────────────────────────────────── + +#[sqlx::test(migrations = "./migrations")] +async fn expired_token_rejected_at_begin(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, _secret) = create_token(&pool, org, 24, None).await; + sqlx::query("UPDATE enrollment_tokens SET expires_at = now() - interval '1 hour'") + .execute(&pool) + .await + .unwrap(); + + let err = begin(&svc, &token_id, &new_device().pubkey).await.unwrap_err(); + assert_eq!(err.code(), Code::NotFound); + // Indistinguishable from a token that never existed. + let unknown = begin(&svc, "tok_doesnotexist", &new_device().pubkey) + .await + .unwrap_err(); + assert_eq!(err.code(), unknown.code()); + assert_eq!(err.message(), unknown.message()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn revoked_token_rejected_even_mid_enrollment(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, None).await; + let dev = new_device(); + + // Begin while valid, revoke, then try to complete: must fail. + let session = begin(&svc, &token_id, &dev.pubkey).await.expect("begin"); + sqlx::query("UPDATE enrollment_tokens SET revoked_at = now() WHERE token_id = $1") + .bind(&token_id) + .execute(&pool) + .await + .unwrap(); + + let err = complete(&svc, complete_request(&session, &secret, &dev)) + .await + .unwrap_err(); + assert_uniform_rejection(&err); + assert!(device_state(&pool, &dev.device_id).await.is_none()); + + // And begin no longer works at all. + let err = begin(&svc, &token_id, &dev.pubkey).await.unwrap_err(); + assert_eq!(err.code(), Code::NotFound); +} + +#[sqlx::test(migrations = "./migrations")] +async fn max_uses_race_admits_exactly_one(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, Some(1)).await; + let (dev_a, dev_b) = (new_device(), new_device()); + + // Both devices get sessions while the token still has a use left, then + // race to complete concurrently. + let session_a = begin(&svc, &token_id, &dev_a.pubkey).await.expect("begin a"); + let session_b = begin(&svc, &token_id, &dev_b.pubkey).await.expect("begin b"); + + let (res_a, res_b) = tokio::join!( + complete(&svc, complete_request(&session_a, &secret, &dev_a)), + complete(&svc, complete_request(&session_b, &secret, &dev_b)), + ); + + let successes = [&res_a, &res_b].iter().filter(|r| r.is_ok()).count(); + assert_eq!(successes, 1, "exactly one of two racing enrollments may win"); + for r in [&res_a, &res_b] { + if let Err(e) = r { + assert_uniform_rejection(e); + } + } + + let (use_count,): (i32,) = + sqlx::query_as("SELECT use_count FROM enrollment_tokens WHERE token_id = $1") + .bind(&token_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(use_count, 1, "use_count must never exceed max_uses"); +} + +// ── rejection paths ───────────────────────────────────────────────────────── + +#[sqlx::test(migrations = "./migrations")] +async fn bad_nonce_signature_rejected(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, None).await; + let dev = new_device(); + + let session = begin(&svc, &token_id, &dev.pubkey).await.expect("begin"); + let mut req = complete_request(&session, &secret, &dev); + req.nonce_signature = dev.key.sign(b"not the nonce").to_bytes().to_vec(); + + assert_uniform_rejection(&complete(&svc, req).await.unwrap_err()); + assert!(device_state(&pool, &dev.device_id).await.is_none()); + assert!(audit_actions(&pool).await.contains(&"enrollment.failed".to_string())); +} + +#[sqlx::test(migrations = "./migrations")] +async fn wrong_secret_rejected(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, _secret) = create_token(&pool, org, 24, None).await; + let dev = new_device(); + + let session = begin(&svc, &token_id, &dev.pubkey).await.expect("begin"); + let req = complete_request(&session, &generate_secret(), &dev); + assert_uniform_rejection(&complete(&svc, req).await.unwrap_err()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn mismatched_device_id_derivation_rejected(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, None).await; + let dev = new_device(); + + let session = begin(&svc, &token_id, &dev.pubkey).await.expect("begin"); + let mut req = complete_request(&session, &secret, &dev); + req.device_id = derive_device_id(&new_device().pubkey); // someone else's id + assert_uniform_rejection(&complete(&svc, req).await.unwrap_err()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn csr_with_foreign_key_rejected(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, None).await; + let dev = new_device(); + + let session = begin(&svc, &token_id, &dev.pubkey).await.expect("begin"); + let mut req = complete_request(&session, &secret, &dev); + // CSR key ≠ enrolled device key (CN is right) — must be rejected. + req.csr_der = make_csr(&new_device().key, &dev.device_id); + assert_uniform_rejection(&complete(&svc, req).await.unwrap_err()); +} + +#[sqlx::test(migrations = "./migrations")] +async fn device_in_other_org_rejected_uniformly(pool: PgPool) { + let svc = service(&pool); + let org_a = create_org(&pool).await; + let org_b = create_org(&pool).await; + let dev = new_device(); + + // The same key is already adopted by org B. + sqlx::query( + "INSERT INTO devices (device_id, org_id, pubkey, state) VALUES ($1, $2, $3, 'adopted')", + ) + .bind(&dev.device_id) + .bind(org_b) + .bind(&dev.pubkey[..]) + .execute(&pool) + .await + .unwrap(); + + let (token_id, secret) = create_token(&pool, org_a, 24, None).await; + let err = enroll(&svc, &token_id, &secret, &dev).await.unwrap_err(); + assert_uniform_rejection(&err); + + // Device stays with org B, and the failed attempt consumed no use. + let (dev_org, state) = device_state(&pool, &dev.device_id).await.unwrap(); + assert_eq!(dev_org, org_b); + assert_eq!(state, "adopted"); + let (use_count,): (i32,) = + sqlx::query_as("SELECT use_count FROM enrollment_tokens WHERE token_id = $1") + .bind(&token_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(use_count, 0); +} + +#[sqlx::test(migrations = "./migrations")] +async fn expired_session_rejected(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, None).await; + let dev = new_device(); + + let session = begin(&svc, &token_id, &dev.pubkey).await.expect("begin"); + sqlx::query("UPDATE enrollment_sessions SET expires_at = now() - interval '1 minute'") + .execute(&pool) + .await + .unwrap(); + assert_uniform_rejection( + &complete(&svc, complete_request(&session, &secret, &dev)) + .await + .unwrap_err(), + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn session_is_single_use(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let (token_id, secret) = create_token(&pool, org, 24, None).await; + let dev = new_device(); + + let session = begin(&svc, &token_id, &dev.pubkey).await.expect("begin"); + complete(&svc, complete_request(&session, &secret, &dev)) + .await + .expect("first completion"); + // Replaying the same session (nonce) must fail. + assert_uniform_rejection( + &complete(&svc, complete_request(&session, &secret, &dev)) + .await + .unwrap_err(), + ); +} + +// ── re-enrollment ─────────────────────────────────────────────────────────── + +#[sqlx::test(migrations = "./migrations")] +async fn revoked_device_can_reenroll_same_org(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let dev = new_device(); + + let (token_id, secret) = create_token(&pool, org, 24, None).await; + enroll(&svc, &token_id, &secret, &dev).await.expect("first enrollment"); + + sqlx::query("UPDATE devices SET state = 'revoked' WHERE device_id = $1") + .bind(&dev.device_id) + .execute(&pool) + .await + .unwrap(); + + let (token2, secret2) = create_token(&pool, org, 24, None).await; + enroll(&svc, &token2, &secret2, &dev).await.expect("re-enrollment"); + + let (_, state) = device_state(&pool, &dev.device_id).await.unwrap(); + assert_eq!(state, "adopted"); + let (via,): (Option,) = + sqlx::query_as("SELECT enrolled_via_token FROM devices WHERE device_id = $1") + .bind(&dev.device_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(via.as_deref(), Some(token2.as_str())); +} + +#[sqlx::test(migrations = "./migrations")] +async fn adopted_device_cannot_silently_reenroll(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let dev = new_device(); + + let (token_id, secret) = create_token(&pool, org, 24, None).await; + enroll(&svc, &token_id, &secret, &dev).await.expect("first enrollment"); + + // Same key, still adopted: a would-be clone with a stolen key and a valid + // token must not get a certificate without an explicit revoke first. + let (token2, secret2) = create_token(&pool, org, 24, None).await; + assert_uniform_rejection(&enroll(&svc, &token2, &secret2, &dev).await.unwrap_err()); +} + +// ── renewal + clone detection ─────────────────────────────────────────────── + +#[sqlx::test(migrations = "./migrations")] +async fn renewal_issues_new_cert_and_revoked_device_is_denied(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let dev = new_device(); + let (token_id, secret) = create_token(&pool, org, 24, None).await; + enroll(&svc, &token_id, &secret, &dev).await.expect("enrollment"); + + let ident = DeviceIdentity { + device_id: dev.device_id.clone(), + org_id: org, + pubkey: dev.pubkey.clone(), + }; + let csr = make_csr(&dev.key, &dev.device_id); + let resp = renew_with_identity(&svc.state, &ident, &csr, None) + .await + .expect("renewal"); + assert!(!resp.client_cert_der.is_empty()); + // Rotation at 2/3 lifetime: renew_after = not_after - lifetime/3. + assert_eq!( + resp.not_after_unix - resp.renew_after_unix, + DEVICE_CERT_DAYS * 24 * 3600 / 3 + ); + assert!(audit_actions(&pool).await.contains(&"cert.renewed".to_string())); + + sqlx::query("UPDATE devices SET state = 'revoked' WHERE device_id = $1") + .bind(&dev.device_id) + .execute(&pool) + .await + .unwrap(); + let err = renew_with_identity(&svc.state, &ident, &csr, None) + .await + .unwrap_err(); + assert_eq!(err.code(), Code::PermissionDenied); +} + +#[sqlx::test(migrations = "./migrations")] +async fn clone_detection_raises_org_event(pool: PgPool) { + let svc = service(&pool); + let org = create_org(&pool).await; + let dev = new_device(); + let (token_id, secret) = create_token(&pool, org, 24, None).await; + enroll(&svc, &token_id, &secret, &dev).await.expect("enrollment"); + + let ip1: IpAddr = "203.0.113.10".parse().unwrap(); + let ip2: IpAddr = "198.51.100.7".parse().unwrap(); + report_contact(&svc.state, &dev.device_id, org, ip1).await; + report_contact(&svc.state, &dev.device_id, org, ip2).await; // concurrent source + + let events: Vec<(String, String)> = + sqlx::query_as("SELECT severity, title FROM org_events WHERE org_id = $1") + .bind(org) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + events, + vec![("warning".to_string(), "Possible cloned device".to_string())] + ); +} diff --git a/frontend/app/(console)/cloud/[organization_guid]/inventory/page.tsx b/frontend/app/(console)/cloud/[organization_guid]/inventory/page.tsx index 30f3908..d836de7 100644 --- a/frontend/app/(console)/cloud/[organization_guid]/inventory/page.tsx +++ b/frontend/app/(console)/cloud/[organization_guid]/inventory/page.tsx @@ -1,12 +1,302 @@ "use client"; -import { CloudSection } from "@/components/CloudSection"; +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { AlertTriangle, Ban, Check, Plus, RotateCw, X } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { Column, DataTable } from "@/components/dashboard/DataTable"; +import { Toast } from "@/components/dashboard/Toast"; +import { useCloudOrg } from "@/components/CloudShell"; +import { AddDeviceModal } from "@/components/AddDeviceModal"; +import { + listDevices, + listEnrollmentTokens, + revokeDevice, + revokeEnrollmentToken, + type Device, + type EnrollmentToken, +} from "@/lib/api"; +/// Two-step inline revoke button (same interaction as RowActions' delete, +/// without the edit half — devices and tokens are revoked, never edited). +function RevokeAction({ label, onRevoke }: { label: string; onRevoke: () => Promise }) { + const [confirming, setConfirming] = useState(false); + const [working, setWorking] = useState(false); + + if (confirming) { + return ( +
+ + +
+ ); + } + return ( + + ); +} + +type TokenStatus = "active" | "expired" | "exhausted" | "revoked"; + +function tokenStatus(t: EnrollmentToken): TokenStatus { + if (t.revoked_at) return "revoked"; + if (new Date(t.expires_at).getTime() <= Date.now()) return "expired"; + if (t.max_uses != null && t.use_count >= t.max_uses) return "exhausted"; + return "active"; +} + +const TOKEN_BADGE: Record = { + active: "badge badge-ok", + expired: "badge badge-muted", + exhausted: "badge badge-muted", + revoked: "badge badge-crit", +}; + +const DEVICE_BADGE: Record = { + adopted: "badge badge-ok", + pending: "badge badge-warn", + revoked: "badge badge-crit", +}; + +const deviceColumns: Column[] = [ + { key: "device_id", header: "Device ID", value: (r) => r.device_id, mono: true, sortable: true, width: 220 }, + { + key: "state", + header: "State", + value: (r) => r.state, + render: (r) => {r.state}, + sortable: true, + width: 100, + }, + { key: "hostname", header: "Hostname", value: (r) => r.hostname, sortable: true }, + { key: "version", header: "Version", value: (r) => r.qf_version, mono: true, width: 100 }, + { + key: "last_seen", + header: "Last seen", + value: (r) => r.last_seen_at, + render: (r) => + r.last_seen_at + ? `${new Date(r.last_seen_at).toLocaleString()}${r.last_seen_ip ? ` · ${r.last_seen_ip}` : ""}` + : "—", + sortable: true, + width: 200, + }, + { + key: "cert", + header: "Cert expires", + value: (r) => r.cert_not_after, + render: (r) => (r.cert_not_after ? new Date(r.cert_not_after).toLocaleDateString() : "—"), + sortable: true, + width: 120, + }, +]; + +const tokenColumns: Column[] = [ + { key: "token_id", header: "Token", value: (r) => r.token_id, mono: true, sortable: true, width: 160 }, + { key: "label", header: "Label", value: (r) => r.label }, + { + key: "status", + header: "Status", + value: (r) => tokenStatus(r), + render: (r) => {tokenStatus(r)}, + sortable: true, + width: 100, + }, + { + key: "uses", + header: "Uses", + value: (r) => r.use_count, + render: (r) => `${r.use_count} / ${r.max_uses ?? "∞"}`, + sortable: true, + width: 80, + }, + { key: "created_by", header: "Created by", value: (r) => r.created_by_email, width: 180 }, + { + key: "expires", + header: "Expires", + value: (r) => r.expires_at, + render: (r) => new Date(r.expires_at).toLocaleString(), + sortable: true, + width: 160, + }, +]; + +/// Inventory: the organization's QuartzFire devices plus the enrollment +/// tokens that adopt them ("Add device" flow). export default function InventoryPage() { + const { org } = useCloudOrg(); + const params = useParams<{ organization_guid: string }>(); + const orgGuid = params.organization_guid; + + const [devices, setDevices] = useState(null); + const [tokens, setTokens] = useState(null); + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const [errorMsg, setErrorMsg] = useState(""); + const [toast, setToast] = useState(null); + const [adding, setAdding] = useState(false); + + const load = useCallback( + async (mode: "load" | "refresh" = "load") => { + if (mode === "load") setStatus("loading"); + try { + const [devs, toks] = await Promise.all([listDevices(orgGuid), listEnrollmentTokens(orgGuid)]); + setDevices(devs); + setTokens(toks); + setStatus("ready"); + } catch (e) { + setErrorMsg(e instanceof Error ? e.message : "Failed to load inventory."); + setStatus("error"); + } + }, + [orgGuid], + ); + + useEffect(() => { + load(); + }, [load]); + + const doRevokeDevice = async (d: Device) => { + try { + await revokeDevice(orgGuid, d.device_id); + setToast(`Revoked device ${d.device_id}.`); + await load("refresh"); + } catch (e) { + setToast(e instanceof Error ? e.message : `Failed to revoke device ${d.device_id}.`); + } + }; + + const doRevokeToken = async (t: EnrollmentToken) => { + try { + await revokeEnrollmentToken(orgGuid, t.token_id); + setToast(`Revoked token ${t.token_id}.`); + await load("refresh"); + } catch (e) { + setToast(e instanceof Error ? e.message : `Failed to revoke token ${t.token_id}.`); + } + }; + return ( - +
+
+

+ Inventory +

+

+ {org?.name ?? "Loading…"} +

+
+ + {status === "loading" && ( +
Loading inventory…
+ )} + {status === "error" && ( +
+
+ + {errorMsg} +
+
+ +
+
+ )} + + {status === "ready" && devices && tokens && ( + <> +
+

Devices

+ r.device_id} + storageKey="org-devices" + searchPlaceholder="Search devices…" + emptyMessage="No devices yet. Add one to enroll your first QuartzFire." + onRefresh={() => load("refresh")} + toolbar={ + + } + actions={(row) => + row.state !== "revoked" ? ( + doRevokeDevice(row)} /> + ) : null + } + /> +
+ +
+

Enrollment tokens

+ r.token_id} + storageKey="org-enroll-tokens" + searchPlaceholder="Search tokens…" + emptyMessage="No enrollment tokens. “Add device” creates one." + onRefresh={() => load("refresh")} + actions={(row) => + tokenStatus(row) === "active" ? ( + doRevokeToken(row)} /> + ) : null + } + /> +
+ + )} + + {adding && ( + { + setAdding(false); + load("refresh"); + }} + onSaved={(msg) => { + // Keep the modal open — it is showing the one-time token string. + setToast(msg); + }} + /> + )} + + {toast && setToast(null)} />} +
); } diff --git a/frontend/components/AddDeviceModal.tsx b/frontend/components/AddDeviceModal.tsx new file mode 100644 index 0000000..57322db --- /dev/null +++ b/frontend/components/AddDeviceModal.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { ModalShell, ModalHeader } from "@/components/ui/Modal"; +import { + createEnrollmentToken, + type CreatedEnrollmentToken, +} from "@/lib/api"; + +const inputCls = "w-full rounded-md px-3 py-[9px] text-[13px] text-[var(--qz-fg-1)] outline-none"; +const inputSt = { background: "var(--qz-input-bg)", border: "1px solid var(--qz-border)" } as const; + +const EXPIRY_OPTIONS = [ + { hours: 1, label: "1 hour" }, + { hours: 8, label: "8 hours" }, + { hours: 24, label: "24 hours" }, + { hours: 72, label: "3 days" }, + { hours: 168, label: "7 days" }, + { hours: 720, label: "30 days" }, +]; + +const USE_OPTIONS = [ + { value: "1", label: "Single device" }, + { value: "5", label: "Up to 5 devices" }, + { value: "25", label: "Up to 25 devices" }, + { value: "", label: "Unlimited" }, +]; + +function focusBorder(e: React.FocusEvent) { + e.currentTarget.style.borderColor = "var(--qz-accent)"; +} +function blurBorder(e: React.FocusEvent) { + e.currentTarget.style.borderColor = "var(--qz-border)"; +} + +/// A code block with a copy button, used for the token string and the CLI +/// one-liner in the created-token view. +function CopyBlock({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + /* clipboard unavailable — the text is selectable */ + } + }; + return ( +
+
+ {label} + +
+
+        {value}
+      
+
+ ); +} + +/// "Add device" flow: create an enrollment token (expiry / use-count / +/// label), then show the QC1|… token string and the QuartzFire CLI one-liner +/// exactly once — the secret is not retrievable after this modal closes. +export function AddDeviceModal({ + orgGuid, + orgName, + onClose, + onSaved, +}: { + orgGuid: string; + orgName?: string; + onClose: () => void; + /** Called after a successful create with a toast-able summary. */ + onSaved: (message: string) => void; +}) { + const [label, setLabel] = useState(""); + const [expiresHours, setExpiresHours] = useState("24"); + const [maxUses, setMaxUses] = useState("1"); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + const [created, setCreated] = useState(null); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSaving(true); + try { + const token = await createEnrollmentToken(orgGuid, { + label: label.trim() || undefined, + expires_hours: Number(expiresHours), + max_uses: maxUses ? Number(maxUses) : undefined, + }); + setCreated(token); + onSaved(`Created enrollment token ${token.token_id}.`); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not create the enrollment token."); + } finally { + setSaving(false); + } + }; + + return ( + + + + {created ? ( +
+ + +

+ The secret half of this token is stored only as a hash. Once this + dialog closes it cannot be displayed again — revoke the token and + create a new one if it is lost. +

+
+ +
+
+ ) : ( +
+
+ + setLabel(e.target.value)} + placeholder="Branch office rollout" + autoComplete="off" + className={inputCls} + style={inputSt} + onFocus={focusBorder} + onBlur={blurBorder} + /> +
+ +
+
+ + +
+
+ + +
+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 28f732b..12aeee1 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -58,3 +58,82 @@ export function createSubOrganization(guid: string, name: string): Promise { return apiFetch(`/orgs/${guid}/subs/${subGuid}`); } + +// ── Device enrollment ─────────────────────────────────────────────────────── + +/** Enrollment-token metadata; the secret only ever appears in the create + * response's `token` field, exactly once. */ +export interface EnrollmentToken { + token_id: string; + label: string | null; + created_at: string; + expires_at: string; + max_uses: number | null; + use_count: number; + revoked_at: string | null; + created_by_email: string | null; +} + +/** Create response: metadata plus the full QC1|… token string (shown once, + * never retrievable again). */ +export interface CreatedEnrollmentToken extends EnrollmentToken { + token: string; +} + +export interface CreateEnrollmentTokenInput { + label?: string; + /** Lifetime in hours (server default 24). */ + expires_hours?: number; + /** Maximum enrollments; omit for unlimited. */ + max_uses?: number; +} + +/** Enrollment tokens of an organization (metadata only). */ +export function listEnrollmentTokens(guid: string): Promise { + return apiFetch(`/orgs/${guid}/enroll-tokens`); +} + +/** Create an enrollment token (owner/admin only). */ +export function createEnrollmentToken( + guid: string, + input: CreateEnrollmentTokenInput, +): Promise { + return apiFetch(`/orgs/${guid}/enroll-tokens`, { + method: "POST", + body: JSON.stringify(input), + }); +} + +/** Revoke an enrollment token (owner/admin only). */ +export function revokeEnrollmentToken(guid: string, tokenId: string): Promise<{ ok: boolean }> { + return apiFetch<{ ok: boolean }>(`/orgs/${guid}/enroll-tokens/${tokenId}/revoke`, { + method: "POST", + }); +} + +/** A QuartzFire device enrolled to (or revoked from) the organization. */ +export interface Device { + device_id: string; + state: "pending" | "adopted" | "revoked"; + hostname: string | null; + qf_version: string | null; + cert_serial: string | null; + cert_not_after: string | null; + enrolled_at: string | null; + enrolled_via_token: string | null; + last_seen_at: string | null; + last_seen_ip: string | null; +} + +/** Devices of an organization. */ +export function listDevices(guid: string): Promise { + return apiFetch(`/orgs/${guid}/devices`); +} + +/** Revoke a device's access (owner/admin only) — it can no longer renew its + * certificate; re-enrolling it later with a fresh token is allowed. */ +export function revokeDevice(guid: string, deviceId: string): Promise<{ ok: boolean }> { + return apiFetch<{ ok: boolean }>(`/orgs/${guid}/devices/${deviceId}/revoke`, { + method: "POST", + }); +} From 00c758bea51d01bb53f6554ac60d959ed0a6eaa8 Mon Sep 17 00:00:00 2001 From: Cody Wellman Date: Sun, 19 Jul 2026 18:32:36 -0400 Subject: [PATCH 2/2] Add universal install and update scripts with PostgreSQL provisioning and nginx TLS on 443, plus README one-liners --- README.md | 35 +++++ scripts/install.sh | 324 +++++++++++++++++++++++++++++++++++++++++++++ scripts/update.sh | 152 +++++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 scripts/install.sh create mode 100644 scripts/update.sh diff --git a/README.md b/README.md index 12da67e..78f74f9 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,41 @@ Users belong to many organizations via the `memberships` table (a role per org). `/cloud` lists the signed-in user's orgs (or redirects when there's exactly one); `/cloud/{organization_guid}` renders an org the user is a member of (403 otherwise). +## Install + +One-liner for Debian/Ubuntu and Fedora/RHEL-family servers (needs systemd): + +```sh +curl -fsSL https://raw.githubusercontent.com/quartzsystems/quartz-command/main/scripts/install.sh | sudo bash +``` + +The script installs PostgreSQL from the distro repos, creates the `quartz` +role and `quartz_command` database with a random password, installs the latest +released `.deb`/`.rpm`, writes `/etc/quartz-command/backend.env`, seeds a +default admin (credentials are printed once at the end), and starts the +`quartz-command-backend` and `quartz-command-frontend` services. Pin a release +with `QC_VERSION=x.y.z`; re-running upgrades the package without touching an +existing database or config. + +The console is served at **`https:///`** — the installer puts nginx on +:443 as a TLS terminator (self-signed certificate, so the browser warns once) +in front of the loopback-only frontend, and opens 443 in firewalld/ufw when +active. To use a real certificate, replace +`/etc/quartz-command/tls/{cert,key}.pem` and `systemctl reload nginx`. + +### Update + +```sh +curl -fsSL https://raw.githubusercontent.com/quartzsystems/quartz-command/main/scripts/update.sh | sudo bash +``` + +Upgrades the package to the latest release without touching the database or +your edited config files, restarts the backend first (migrations run on +startup) and verifies `/api/health` before restarting the frontend. On +failure it prints a pinned rollback one-liner. `QC_VERSION=x.y.z` targets a +specific release; add `QC_ALLOW_DOWNGRADE=1` to roll back (schema migrations +are forward-only — don't roll back across a release that migrated). + ## Development 1. **Database** — run PostgreSQL yourself (local install, managed service, etc.), diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..063c96b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# Universal Quartz Command installer for Debian/Ubuntu and Fedora/RHEL-family +# Linux. Installs PostgreSQL from the distro repos, provisions the role and +# database, installs the latest quartz-command .deb/.rpm from GitHub releases, +# writes /etc/quartz-command/backend.env, puts nginx on :443 (self-signed TLS, +# proxying the loopback frontend), and starts the systemd services. +# +# curl -fsSL https://raw.githubusercontent.com/quartzsystems/quartz-command/main/scripts/install.sh | sudo bash +# +# Environment overrides: +# QC_REPO=owner/repo GitHub repo to download from (default quartzsystems/quartz-command) +# QC_VERSION=1.2.3 Install a specific release instead of the latest +# +# Safe to re-run: existing PostgreSQL data, an already-configured backend.env, +# and running services are left alone; the package itself is upgraded. +set -euo pipefail + +QC_REPO="${QC_REPO:-quartzsystems/quartz-command}" +QC_VERSION="${QC_VERSION:-}" + +ENV_FILE=/etc/quartz-command/backend.env +TLS_DIR=/etc/quartz-command/tls +NGINX_CONF=/etc/nginx/conf.d/quartz-command.conf +DB_NAME=quartz_command +DB_ROLE=quartz + +log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "this installer must run as root (re-run with sudo)" +command -v systemctl >/dev/null || die "systemd is required" + +# ── distro detection ──────────────────────────────────────────────────────── + +[ -r /etc/os-release ] || die "cannot detect the distribution (/etc/os-release missing)" +. /etc/os-release +case " $ID ${ID_LIKE:-} " in + *" debian "*|*" ubuntu "*) FAMILY=deb ;; + *" fedora "*|*" rhel "*|*" centos "*) FAMILY=rpm ;; + *) die "unsupported distribution: $ID (Debian/Ubuntu and Fedora/RHEL families are supported)" ;; +esac + +if [ "$FAMILY" = rpm ]; then + PKG=dnf + command -v dnf >/dev/null || PKG=yum +fi + +log "Detected $PRETTY_NAME ($FAMILY-family)" + +# ── prerequisites ─────────────────────────────────────────────────────────── + +if [ "$FAMILY" = deb ]; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + command -v curl >/dev/null || apt-get install -y -qq curl ca-certificates +else + command -v curl >/dev/null || "$PKG" install -y -q curl +fi + +# 32 hex chars from 16 CSPRNG bytes. Deliberately avoids the classic +# `tr /dev/null + fi + fi + systemctl enable --now "$PG_SERVICE" >/dev/null 2>&1 +} + +# Run psql as the postgres superuser (cd / avoids cwd-permission noise). +pg() { (cd / && runuser -u postgres -- psql -v ON_ERROR_STOP=1 -tAc "$1"); } + +# The RHEL-family default pg_hba.conf uses `ident` for TCP connections, which +# rejects password logins; switch those lines to scram so the backend can +# authenticate over 127.0.0.1. (Debian's default is already scram/md5.) +fix_pg_hba() { + local hba + hba="$(pg 'SHOW hba_file')" + if grep -Eq '^host.*\bident$' "$hba"; then + log "Switching pg_hba host auth from ident to scram-sha-256" + sed -i -E 's/^(host.*[[:space:]])ident$/\1scram-sha-256/' "$hba" + systemctl reload "$PG_SERVICE" + fi +} + +provision_database() { + DB_PASSWORD="$(random_secret)" + if pg "SELECT 1 FROM pg_roles WHERE rolname = '$DB_ROLE'" | grep -q 1; then + log "Role '$DB_ROLE' exists — resetting its password for this install" + pg "ALTER ROLE $DB_ROLE WITH LOGIN PASSWORD '$DB_PASSWORD'" >/dev/null + else + log "Creating PostgreSQL role '$DB_ROLE'" + pg "CREATE ROLE $DB_ROLE WITH LOGIN PASSWORD '$DB_PASSWORD'" >/dev/null + fi + if ! pg "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1; then + log "Creating database '$DB_NAME'" + pg "CREATE DATABASE $DB_NAME OWNER $DB_ROLE" >/dev/null + fi +} + +# ── web front end: nginx on :443 ──────────────────────────────────────────── +# The packaged Next.js server is plain HTTP on 127.0.0.1:3000 and runs +# unprivileged; nginx terminates TLS on 443 in front of it so the console is +# reachable at https:/// out of the box. The generated self-signed cert +# lives in /etc/quartz-command/tls/ — drop a real cert/key over it and +# `systemctl reload nginx` to replace it. + +install_web_proxy() { + log "Installing nginx (TLS front end on :443)" + if [ "$FAMILY" = deb ]; then + apt-get install -y -qq nginx openssl + else + "$PKG" install -y -q nginx openssl + fi + + if [ ! -f "$TLS_DIR/cert.pem" ]; then + log "Generating a self-signed TLS certificate" + mkdir -p "$TLS_DIR" + local host + host="$(hostname -f 2>/dev/null || hostname)" + openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ + -keyout "$TLS_DIR/key.pem" -out "$TLS_DIR/cert.pem" \ + -subj "/CN=$host" \ + -addext "subjectAltName=DNS:$host,IP:127.0.0.1" >/dev/null 2>&1 \ + || die "could not generate the TLS certificate" + chmod 0600 "$TLS_DIR/key.pem" + fi + + cat > "$NGINX_CONF" <<'EOF' +# Quartz Command web console — TLS termination for the Next.js frontend on +# 127.0.0.1:3000 (which forwards /api to the backend itself). Written by +# scripts/install.sh. To use a real certificate, replace the files in +# /etc/quartz-command/tls/ and `systemctl reload nginx`. +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name _; + + ssl_certificate /etc/quartz-command/tls/cert.pem; + ssl_certificate_key /etc/quartz-command/tls/key.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} +EOF + + # SELinux (RHEL family): out of the box nginx may not open outbound + # connections, which would 502 every request to the upstream on :3000. + if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" = "Enforcing" ]; then + log "Allowing nginx to reach the frontend (SELinux httpd_can_network_connect)" + setsebool -P httpd_can_network_connect 1 \ + || warn "could not set httpd_can_network_connect — nginx may return 502" + fi + + nginx -t >/dev/null 2>&1 || die "nginx configuration test failed (nginx -t)" + systemctl enable --now nginx >/dev/null 2>&1 + systemctl reload nginx >/dev/null 2>&1 || true + + open_firewall +} + +open_firewall() { + if systemctl is-active firewalld >/dev/null 2>&1; then + log "Opening https in firewalld" + (firewall-cmd --permanent --add-service=https >/dev/null \ + && firewall-cmd --reload >/dev/null) \ + || warn "could not open 443/tcp in firewalld" + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + log "Opening 443/tcp in ufw" + ufw allow 443/tcp >/dev/null 2>&1 || warn "could not open 443/tcp in ufw" + fi +} + +# ── quartz-command package ────────────────────────────────────────────────── + +download_package() { + local api asset_filter json url tmp + if [ -n "$QC_VERSION" ]; then + api="https://api.github.com/repos/$QC_REPO/releases/tags/v${QC_VERSION#v}" + else + api="https://api.github.com/repos/$QC_REPO/releases/latest" + fi + + if [ "$FAMILY" = deb ]; then + asset_filter="_$(dpkg --print-architecture)\.deb" + else + asset_filter="$(uname -m)\.rpm" + fi + + log "Looking up release assets ($api)" + json="$(curl -fsSL "$api")" || die "could not query GitHub releases for $QC_REPO" + url="$(printf '%s' "$json" \ + | grep -oE '"browser_download_url": *"[^"]+"' \ + | grep -oE 'https://[^"]+' \ + | grep -E "$asset_filter" | head -n 1 || true)" + [ -n "$url" ] || die "no ${FAMILY} package matching '$asset_filter' in the release — build one with scripts/build-${FAMILY}.sh" + + tmp="$(mktemp -d)" + PKG_FILE="$tmp/${url##*/}" + log "Downloading ${url##*/}" + curl -fsSL -o "$PKG_FILE" "$url" +} + +install_package() { + log "Installing quartz-command package" + if [ "$FAMILY" = deb ]; then + # apt resolves the nodejs dependency from the distro repos. + apt-get install -y -qq "$PKG_FILE" || die "package install failed — the distro's nodejs may be older than 18.17; install Node 18+ and re-run" + else + if ! "$PKG" install -y -q "$PKG_FILE"; then + # RHEL 8/9 default nodejs module stream can be < 18; try a newer + # stream and retry once. (Fedora has no module streams; this is a + # harmless no-op failure there.) + warn "install failed — enabling the nodejs:20 module stream and retrying" + "$PKG" -y module reset nodejs >/dev/null 2>&1 || true + "$PKG" -y module enable nodejs:20 >/dev/null 2>&1 || true + "$PKG" install -y -q "$PKG_FILE" || die "package install failed" + fi + fi +} + +# ── configuration ─────────────────────────────────────────────────────────── + +configure_backend() { + [ -f "$ENV_FILE" ] || die "$ENV_FILE missing after package install" + + if ! grep -q 'CHANGE_ME' "$ENV_FILE"; then + log "$ENV_FILE already configured — leaving it untouched" + ADMIN_PASSWORD="" + return + fi + + log "Writing DATABASE_URL to $ENV_FILE" + sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgres://$DB_ROLE:$DB_PASSWORD@127.0.0.1/$DB_NAME|" "$ENV_FILE" + + # QC_COOKIE_SECURE stays true (the template default): the console is + # served over TLS by nginx on :443. + + # Seed a default admin (only takes effect while the admins table is empty). + ADMIN_PASSWORD="$(random_secret)" + sed -i "s|^#QC_DEFAULT_ADMIN_EMAIL=.*|QC_DEFAULT_ADMIN_EMAIL=admin@quartz.local|" "$ENV_FILE" + sed -i "s|^#QC_DEFAULT_ADMIN_PASSWORD=.*|QC_DEFAULT_ADMIN_PASSWORD=$ADMIN_PASSWORD|" "$ENV_FILE" +} + +start_services() { + log "Starting services" + systemctl daemon-reload + systemctl enable --now quartz-command-backend quartz-command-frontend >/dev/null 2>&1 + + log "Waiting for the backend to come up" + local backend_ok="" + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then + backend_ok=1 + break + fi + sleep 1 + done + [ -n "$backend_ok" ] \ + || warn "backend did not answer /api/health yet — check: journalctl -u quartz-command-backend" + + log "Waiting for the console on :443" + for _ in $(seq 1 30); do + # -k: the generated cert is self-signed. + if curl -fsSk https://127.0.0.1/login >/dev/null 2>&1; then + return + fi + sleep 1 + done + warn "https://127.0.0.1/ not answering yet — check: journalctl -u quartz-command-frontend -u nginx" +} + +# ── run ───────────────────────────────────────────────────────────────────── + +install_postgres +fix_pg_hba +provision_database +install_web_proxy +download_package +install_package +configure_backend +start_services + +HOST_ADDR="$(hostname -I 2>/dev/null | awk '{print $1}')" +HOST_ADDR="${HOST_ADDR:-127.0.0.1}" + +echo +log "Quartz Command is installed." +echo +echo " Web console: https://$HOST_ADDR/login" +echo " Admin console: https://$HOST_ADDR/admin/login" +if [ -n "${ADMIN_PASSWORD:-}" ]; then + echo + echo " Default admin account (change the password after first login):" + echo " email: admin@quartz.local" + echo " password: $ADMIN_PASSWORD" +fi +echo +echo " Config: /etc/quartz-command/{backend,frontend}.env" +echo " Logs: journalctl -u quartz-command-backend -u quartz-command-frontend -u nginx" +echo +echo " The console is served by nginx on :443 with a self-signed certificate" +echo " (your browser will warn once). To use a real certificate, replace" +echo " $TLS_DIR/{cert,key}.pem and run: systemctl reload nginx" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..14c59e7 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Gracefully update an existing Quartz Command install (Debian/Ubuntu and +# Fedora/RHEL families). Downloads the requested release package, upgrades it +# in place, restarts the backend (embedded migrations run on boot), verifies +# health, then restarts the frontend. The database and the (conffile-marked) +# /etc/quartz-command/*.env files are never touched. +# +# curl -fsSL https://raw.githubusercontent.com/quartzsystems/quartz-command/main/scripts/update.sh | sudo bash +# +# Environment overrides: +# QC_REPO=owner/repo GitHub repo to download from (default quartzsystems/quartz-command) +# QC_VERSION=1.2.3 Update (or roll back) to a specific release instead of the latest +# QC_ALLOW_DOWNGRADE=1 Permit installing an older version than the current one. +# Note: database migrations are forward-only — rolling back +# across a release that migrated the schema may not work. +set -euo pipefail + +QC_REPO="${QC_REPO:-quartzsystems/quartz-command}" +QC_VERSION="${QC_VERSION:-}" +QC_ALLOW_DOWNGRADE="${QC_ALLOW_DOWNGRADE:-}" + +log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "this updater must run as root (re-run with sudo)" +command -v systemctl >/dev/null || die "systemd is required" +command -v curl >/dev/null || die "curl is required" + +# ── distro detection ──────────────────────────────────────────────────────── + +[ -r /etc/os-release ] || die "cannot detect the distribution (/etc/os-release missing)" +. /etc/os-release +case " $ID ${ID_LIKE:-} " in + *" debian "*|*" ubuntu "*) FAMILY=deb ;; + *" fedora "*|*" rhel "*|*" centos "*) FAMILY=rpm ;; + *) die "unsupported distribution: $ID (Debian/Ubuntu and Fedora/RHEL families are supported)" ;; +esac + +if [ "$FAMILY" = rpm ]; then + PKG=dnf + command -v dnf >/dev/null || PKG=yum +fi + +# ── current install ───────────────────────────────────────────────────────── + +if [ "$FAMILY" = deb ]; then + INSTALLED="$(dpkg-query -W -f='${Version}' quartz-command 2>/dev/null || true)" +else + INSTALLED="$(rpm -q --qf '%{VERSION}' quartz-command 2>/dev/null || true)" + case "$INSTALLED" in *"not installed"*) INSTALLED="" ;; esac +fi +[ -n "$INSTALLED" ] || die "quartz-command is not installed — use scripts/install.sh for a fresh install" + +log "Installed version: $INSTALLED" + +# ── target release ────────────────────────────────────────────────────────── + +if [ -n "$QC_VERSION" ]; then + API="https://api.github.com/repos/$QC_REPO/releases/tags/v${QC_VERSION#v}" +else + API="https://api.github.com/repos/$QC_REPO/releases/latest" +fi +JSON="$(curl -fsSL "$API")" || die "could not query GitHub releases for $QC_REPO" +TARGET="$(printf '%s' "$JSON" | grep -oE '"tag_name": *"[^"]+"' | head -n 1 \ + | grep -oE 'v?[0-9][^"]*' | sed 's/^v//')" +[ -n "$TARGET" ] || die "could not determine the release version from $API" + +if [ "$TARGET" = "$INSTALLED" ]; then + log "Already on $INSTALLED — nothing to do." + exit 0 +fi +log "Updating $INSTALLED → $TARGET" + +if [ "$FAMILY" = deb ]; then + ASSET_FILTER="_$(dpkg --print-architecture)\.deb" +else + ASSET_FILTER="$(uname -m)\.rpm" +fi +URL="$(printf '%s' "$JSON" \ + | grep -oE '"browser_download_url": *"[^"]+"' \ + | grep -oE 'https://[^"]+' \ + | grep -E "$ASSET_FILTER" | head -n 1 || true)" +[ -n "$URL" ] || die "release v$TARGET has no ${FAMILY} package matching '$ASSET_FILTER'" + +TMP="$(mktemp -d)" +PKG_FILE="$TMP/${URL##*/}" +log "Downloading ${URL##*/}" +curl -fsSL -o "$PKG_FILE" "$URL" + +# ── upgrade ───────────────────────────────────────────────────────────────── +# The package upgrade replaces binaries only; dpkg conffiles / rpm +# %config(noreplace) keep the existing /etc/quartz-command/*.env. Services +# keep running the old code until we restart them below. + +log "Installing quartz-command $TARGET" +if [ "$FAMILY" = deb ]; then + if [ -n "$QC_ALLOW_DOWNGRADE" ]; then + apt-get install -y -qq --allow-downgrades "$PKG_FILE" || die "package install failed" + else + apt-get install -y -qq "$PKG_FILE" \ + || die "package install failed (downgrade? re-run with QC_ALLOW_DOWNGRADE=1)" + fi +else + if [ -n "$QC_ALLOW_DOWNGRADE" ]; then + "$PKG" downgrade -y -q "$PKG_FILE" 2>/dev/null || "$PKG" install -y -q "$PKG_FILE" \ + || die "package install failed" + else + "$PKG" install -y -q "$PKG_FILE" \ + || die "package install failed (downgrade? re-run with QC_ALLOW_DOWNGRADE=1)" + fi +fi + +# ── graceful restart: backend first (migrations), verify, then frontend ───── + +ROLLBACK_HINT="QC_VERSION=$INSTALLED QC_ALLOW_DOWNGRADE=1 curl -fsSL https://raw.githubusercontent.com/$QC_REPO/main/scripts/update.sh | sudo bash" + +log "Restarting backend (database migrations run on startup)" +systemctl restart quartz-command-backend + +log "Waiting for the backend to become healthy" +BACKEND_OK="" +for _ in $(seq 1 60); do + if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then + BACKEND_OK=1 + break + fi + sleep 1 +done +if [ -z "$BACKEND_OK" ]; then + warn "backend is not healthy after the update" + warn " inspect: journalctl -u quartz-command-backend -n 50" + warn " roll back: $ROLLBACK_HINT" + die "update to $TARGET failed health check" +fi + +log "Restarting frontend" +systemctl restart quartz-command-frontend +for _ in $(seq 1 30); do + if curl -fsSk https://127.0.0.1/login >/dev/null 2>&1 \ + || curl -fsS http://127.0.0.1:3000/login >/dev/null 2>&1; then + break + fi + sleep 1 +done + +echo +log "Quartz Command updated: $INSTALLED → $TARGET" +echo +echo " If anything looks wrong:" +echo " logs: journalctl -u quartz-command-backend -u quartz-command-frontend" +echo " roll back: $ROLLBACK_HINT"