git-ents.gitmain
⌘K
foforge
commit 26a888c
feat: sign in through the CLI and debug a repo's checks Sprite over it

Auth proofs that used to require a browser now work from git ents directly: the CLI signs the server’s one-time challenge with the same key a signed push uses, so it can complete the exact login the web UI does without ever pasting anything by hand. That session then authorizes a second command - an interactive, read-write shell in the repository’s persistent checks Sprite - brokered over a WebSocket so the member never needs their own Fly credential; the server holds the one SPRITES_TOKEN and relays bytes while sprite exec --tty handles the remote pseudo-TTY.

feat: add plain-text /login/cli challenge and verify endpoints for CLI sign-in feat: add git ents login to sign in via a CLI-signed challenge feat: add /_debug/<repo> WebSocket broker for an interactive checks-Sprite shell feat: add git ents checks debug to open a raw-mode shell over it Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -45,6 +45,12 @@ "url", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -105,7 +111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -116,7 +122,7 @@ dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -458,6 +464,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -476,8 +483,10 @@ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -503,6 +512,12 @@ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-toml" version = "0.1.10" @@ -668,6 +683,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -707,6 +731,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook 0.3.18", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -752,6 +803,12 @@ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "defmt" version = "1.1.0" @@ -784,6 +841,28 @@ "thiserror", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -813,7 +892,16 @@ dependencies = [ "cfg-if", "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -844,7 +932,7 @@ checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -987,6 +1075,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1034,6 +1132,12 @@ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -1054,6 +1158,7 @@ dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -1069,6 +1174,29 @@ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -1077,7 +1205,7 @@ dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -1085,11 +1213,17 @@ version = "0.0.0" dependencies = [ "clap", + "crossterm", "facet", + "form_urlencoded", + "futures-util", "git-store", "iddqd", "tempfile", "thiserror", + "tokio", + "tokio-tungstenite", + "ureq", ] [[package]] @@ -1106,7 +1240,7 @@ "clap_mangen", "facet", "form_urlencoded", - "getrandom", + "getrandom 0.4.3", "git-ents", "git-store", "gix-actor", @@ -1188,7 +1322,7 @@ "nonempty", "parking_lot", "regex", - "signal-hook", + "signal-hook 0.4.4", "smallvec 1.15.2", "thiserror", ] @@ -1830,7 +1964,7 @@ "bitflags 2.13.0", "gix-path", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1894,7 +2028,7 @@ "gix-fs", "libc", "parking_lot", - "signal-hook", + "signal-hook 0.4.4", "signal-hook-registry", "tempfile", ] @@ -2426,6 +2560,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2503,6 +2643,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -2510,8 +2660,9 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2633,6 +2784,15 @@ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2705,12 +2865,47 @@ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2755,6 +2950,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roff" version = "1.1.1" @@ -2815,7 +3024,42 @@ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2956,6 +3200,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook" version = "0.4.4" @@ -2966,6 +3220,17 @@ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook 0.3.18", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2976,6 +3241,12 @@ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -3001,7 +3272,7 @@ checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3028,6 +3299,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -3063,10 +3340,10 @@ checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3127,7 +3404,7 @@ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3141,6 +3418,32 @@ "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-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3237,6 +3540,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" @@ -3273,6 +3594,12 @@ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unsynn" version = "0.3.0" @@ -3284,6 +3611,41 @@ "rustc-hash", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.8", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -3296,6 +3658,12 @@ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3314,7 +3682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -3341,6 +3709,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -3386,6 +3763,24 @@ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3408,7 +3803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3476,6 +3871,15 @@ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3485,6 +3889,70 @@ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.3" @@ -3494,6 +3962,12 @@ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -3523,6 +3997,26 @@ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -3544,6 +4038,12 @@ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4"
crates/git-ents-server/Cargo.toml @@ -12,7 +12,7 @@ acdc-converters-core = { workspace = true } acdc-converters-html = { workspace = true } arborium = { workspace = true } -axum = { workspace = true } +axum = { workspace = true, features = ["ws"] } clap = { workspace = true } clap_mangen = { workspace = true } facet = { workspace = true }
crates/git-ents/Cargo.toml @@ -7,11 +7,17 @@ [dependencies] clap = { workspace = true } +crossterm = "0.29.0" facet = { workspace = true } +form_urlencoded.workspace = true +futures-util = { version = "0.3.32", default-features = false, features = ["sink", "std"] } git-store = { workspace = true } iddqd = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } +tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } +ureq = "3.3.0" [lints] workspace = true
crates/git-ents-server/src/checks.rs @@ -314,7 +314,10 @@ /// A Sprite name derived from the repository directory, kept to the /// `[a-z0-9-]` a Sprite name allows so the same repo reuses the same sandbox. -fn sprite_name(repo: &Path) -> String { +/// +/// Shared with [`crate::web`]'s debug-session broker, which targets the same +/// persistent per-repo Sprite a check run used. +pub(crate) fn sprite_name(repo: &Path) -> String { let stem = repo .file_name() .map(|name| name.to_string_lossy()) @@ -341,7 +344,7 @@ /// token per call, so without this it reports "no organizations configured" /// even with the token in the environment. `auth setup` is idempotent, so it is /// run on every push to keep the steady state self-healing. -fn ensure_auth() -> Result<(), String> { +pub(crate) fn ensure_auth() -> Result<(), String> { let token = std::env::var("SPRITES_TOKEN") .ok() .ok_or("SPRITES_TOKEN is not set in the hook environment")?; @@ -363,7 +366,7 @@ /// fails when the Sprite is already there, which is the steady state once the /// first push has run, so its failure is tolerated and surfaces only later if /// the Sprite turns out to be unreachable. -fn ensure_sprite(sprite: &str) -> Result<(), String> { +pub(crate) fn ensure_sprite(sprite: &str) -> Result<(), String> { let _existing = Command::new("sprite") .args(["create", "--skip-console", sprite]) .output()
crates/git-ents-server/src/main.rs @@ -161,6 +161,7 @@ let app = Router::new() .route("/healthz", get(http::health)) .route("/", get(http::get_request)) + .route("/_debug/{*path}", get(web::handshake)) .route("/{*path}", get(http::get_request).post(http::post_request)) .layer(DefaultBodyLimit::disable()) .with_state(state);
crates/git-ents/src/main.rs @@ -8,6 +8,8 @@ //! `refs/meta/member/*` refs into the local repository, editing them through //! [`git_ents::members`], and pushing them back. +mod debug_session; + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Stdio}; @@ -42,6 +44,17 @@ #[command(subcommand)] action: ChecksAction, }, + /// Sign in to a remote's server the same way the web UI does — sign a + /// server-issued challenge with your key — so this machine can also open a + /// debug session (`checks debug`). + Login { + /// Remote whose server to sign in to. + #[arg(default_value = "origin")] + remote: String, + /// Key to sign in with; defaults to `user.signingkey`. + #[arg(long)] + key: Option<PathBuf>, + }, } #[derive(Subcommand)] @@ -171,6 +184,14 @@ #[arg(default_value = "origin")] remote: String, }, + /// Open an interactive, read-write shell in `remote`'s persistent checks + /// Sprite — the same sandbox its check runs execute in. Requires + /// `git ents login <remote>` first. + Debug { + /// Remote whose checks Sprite to open a shell in. + #[arg(default_value = "origin")] + remote: String, + }, } fn main() -> ExitCode { @@ -179,6 +200,7 @@ Top::Members { action } => run_members(action), Top::Account { action } => run_account(action), Top::Checks { action } => run_checks(action), + Top::Login { remote, key } => login(&remote, key.as_deref()), }; match result { Ok(()) => ExitCode::SUCCESS, @@ -242,6 +264,7 @@ remote, } => add_check(&name, &command, &remote), ChecksAction::Remove { name, remote } => remove::<Checks>(&name, &remote), + ChecksAction::Debug { remote } => checks_debug(&remote), } } @@ -712,6 +735,209 @@ Ok(()) } +/// The SSHSIG namespace a sign-in signature is made under; must match the +/// server's `git-ents-server::web::write::LOGIN_NAMESPACE`. +const LOGIN_NAMESPACE: &str = "git-ents-login"; + +/// Sign in to `remote`'s server: fetch its one-time challenge, sign it locally +/// with `key` (never handing the private key anywhere), and post the +/// signature back — the same proof the browser login page collects by hand. +/// The returned session token is stored locally so `checks_debug` can reuse +/// it. +fn login(remote: &str, key: Option<&Path>) -> Result<(), String> { + let (base, _repo_path) = remote_http_base(remote)?; + let private_key = signing_key_file(key)?; + let public_key = public_key(key)?; + + let nonce = http_get(&format!("{base}/login/cli"))?; + let signature = sign_challenge(&private_key, &nonce)?; + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("public_key", &public_key) + .append_pair("signature", &signature) + .append_pair("nonce", &nonce) + .finish(); + let token = http_post_form(&format!("{base}/login/cli"), &body)?; + + store_session(&host_of(&base)?, &token)?; + println!("signed in to {remote}"); + Ok(()) +} + +/// Open an interactive, read-write shell in `remote`'s persistent checks +/// Sprite, brokered by the server over a WebSocket using the session +/// `login` stored. +fn checks_debug(remote: &str) -> Result<(), String> { + let (base, repo_path) = remote_http_base(remote)?; + let host = host_of(&base)?; + let token = load_session(&host)? + .ok_or_else(|| format!("not signed in to {remote}; run `git ents login {remote}` first"))?; + let ws_url = format!("{}/_debug/{repo_path}", to_ws(&base)); + + let runtime = tokio::runtime::Runtime::new() + .map_err(|error| format!("could not start the async runtime: {error}"))?; + runtime.block_on(crate::debug_session::run(&ws_url, &token)) +} + +/// The path to the private half of the signing key to use: `key` verbatim, or +/// the path behind `user.signingkey`, resolved the same way `setup` does. +fn signing_key_file(key: Option<&Path>) -> Result<PathBuf, String> { + match key { + Some(path) => Ok(key_paths(path).0), + None => { + let configured = config_get("user.signingkey") + .ok_or("no --key given and user.signingkey is unset")?; + Ok(key_paths(&signing_key_path(&configured)).0) + } + } +} + +/// Sign `nonce` under [`LOGIN_NAMESPACE`] with the private key at `path`, +/// returning the armored SSH signature. `ssh-keygen -Y sign` only writes a +/// signature next to a file it read, so the nonce is staged there first. +fn sign_challenge(private_key: &Path, nonce: &str) -> Result<String, String> { + let dir = tempfile::tempdir().map_err(|error| format!("could not create temp dir: {error}"))?; + let data = dir.path().join("nonce"); + std::fs::write(&data, nonce).map_err(|error| format!("could not write challenge: {error}"))?; + let status = Command::new("ssh-keygen") + .args(["-Y", "sign", "-f"]) + .arg(private_key) + .args(["-n", LOGIN_NAMESPACE]) + .arg(&data) + .status() + .map_err(|error| format!("could not run ssh-keygen: {error}"))?; + if !status.success() { + return Err("ssh-keygen could not sign the challenge".to_owned()); + } + std::fs::read_to_string(dir.path().join("nonce.sig")) + .map_err(|error| format!("could not read the signature: {error}")) +} + +/// The server's http(s) base URL and repository path (without `.git`) for +/// `remote`'s configured URL, e.g. `https://ents.example.com` and `org/repo`. +fn remote_http_base(remote: &str) -> Result<(String, String), String> { + let url = git_capture(&["remote", "get-url", remote])?; + let url = url.trim(); + let (scheme, rest) = url + .split_once("://") + .ok_or_else(|| format!("{remote} is not an http(s) remote; login and debug need one"))?; + if scheme != "http" && scheme != "https" { + return Err(format!( + "{remote} is not an http(s) remote; login and debug need one" + )); + } + let (host, path) = rest.split_once('/').unwrap_or((rest, "")); + let repo_path = path.strip_suffix(".git").unwrap_or(path).trim_matches('/'); + Ok((format!("{scheme}://{host}"), repo_path.to_owned())) +} + +/// The `host[:port]` portion of an `http(s)://host[:port]` base URL. +fn host_of(base: &str) -> Result<String, String> { + base.split_once("://") + .map(|(_scheme, host)| host.to_owned()) + .ok_or_else(|| "malformed server URL".to_owned()) +} + +/// Rewrite an `http(s)://` base URL to its `ws(s)://` equivalent. +fn to_ws(base: &str) -> String { + if let Some(rest) = base.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = base.strip_prefix("http://") { + format!("ws://{rest}") + } else { + base.to_owned() + } +} + +/// GET `url`, returning the response body, or its body text as the error on a +/// non-2xx status. +fn http_get(url: &str) -> Result<String, String> { + let mut response = ureq::get(url) + .config() + .http_status_as_error(false) + .build() + .call() + .map_err(|error| format!("GET {url} failed: {error}"))?; + let status = response.status(); + let text = response + .body_mut() + .read_to_string() + .map_err(|error| format!("could not read the response: {error}"))?; + if status.is_success() { + Ok(text) + } else if text.is_empty() { + Err(format!("GET {url} returned {status}")) + } else { + Err(text) + } +} + +/// POST an `application/x-www-form-urlencoded` `body` to `url`, returning the +/// response body, or its body text as the error on a non-2xx status. +fn http_post_form(url: &str, body: &str) -> Result<String, String> { + let mut response = ureq::post(url) + .config() + .http_status_as_error(false) + .build() + .header("Content-Type", "application/x-www-form-urlencoded") + .send(body) + .map_err(|error| format!("POST {url} failed: {error}"))?; + let status = response.status(); + let text = response + .body_mut() + .read_to_string() + .map_err(|error| format!("could not read the response: {error}"))?; + if status.is_success() { + Ok(text) + } else if text.is_empty() { + Err(format!("POST {url} returned {status}")) + } else { + Err(text) + } +} + +/// Where `login` stores the session token for `host`, one file per host. +fn session_path(host: &str) -> Result<PathBuf, String> { + let home = std::env::var("HOME").map_err(|_unset| "HOME is not set".to_owned())?; + let sanitized: String = host + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-') { + c + } else { + '_' + } + }) + .collect(); + Ok(Path::new(&home) + .join(".config/git-ents/sessions") + .join(sanitized)) +} + +/// Persist the session `token` for `host`, restricted to the owner. +fn store_session(host: &str, token: &str) -> Result<(), String> { + let path = session_path(host)?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .map_err(|error| format!("could not create {}: {error}", dir.display()))?; + } + std::fs::write(&path, token).map_err(|error| format!("could not write session: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let _permissions = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +/// The stored session token for `host`, if `login` has been run against it. +fn load_session(host: &str) -> Result<Option<String>, String> { + match std::fs::read_to_string(session_path(host)?) { + Ok(token) => Ok(Some(token.trim().to_owned())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("could not read the stored session: {error}")), + } +} + /// This client's own signing-key fingerprint, best-effort — `None` when no key /// is configured or it cannot be read. fn own_fingerprint() -> Option<String> {
crates/git-ents-server/src/web/mod.rs @@ -9,6 +9,7 @@ //! This file owns routing and the shared page shell. mod assets; +mod debug; mod git; mod icons; mod pages; @@ -26,6 +27,7 @@ use crate::AppState; use crate::http::{MAX_REPO_DEPTH, is_bare_repo, valid_segment}; +pub(crate) use self::debug::handshake; pub(crate) use self::write::{Challenges, Sessions, new_challenges, new_sessions}; /// Who is signed in for the current request, resolved per repository: a member's @@ -65,6 +67,14 @@ }; return login_page(session.as_ref(), challenge.as_deref(), None).into_response(); } + // The CLI signs in the same way the browser form does, just without the + // HTML: a bare nonce to sign, and (via `handle_post`) a bare token back. + if segments == ["login", "cli"] { + return match write::issue_challenge(&state.challenges) { + Ok(nonce) => nonce.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + }; + } if let Some((repo, rel, rest)) = resolve_repo(&state.data_dir, &segments) { return route(&repo, &rel, rest, host, session, editing_enabled(state)).await; @@ -123,6 +133,12 @@ } }; } + if segments == ["login", "cli"] { + return match write::login(&state.sessions, &state.challenges, &body) { + Ok(token) => token.into_response(), + Err(error) => (StatusCode::UNAUTHORIZED, error).into_response(), + }; + } if segments == ["logout"] { // A cross-site form cannot read the session's CSRF token, so an absent // or wrong one means the request did not originate from our own page.
crates/git-ents-server/src/web/debug.rs @@ -1,0 +1,123 @@ +//! Interactive debug sessions into a repository's checks Sprite. +//! +//! A member who can already sign in to the web UI (see [`super::write`]) can +//! open a read-write shell in the same persistent Sprite a check run used, +//! brokered over a WebSocket so the member never needs a Fly credential of +//! their own: the server holds the one `SPRITES_TOKEN` and relays bytes. +//! Reachable at the reserved top-level path `/_debug/<repo-path>` — a repo +//! literally named `_debug` is shadowed, the same tradeoff `/login` already +//! makes against a repo named `login`. + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::process::Command; + +use crate::AppState; + +/// Upgrade an authenticated member's request into an interactive shell in +/// `repo_path`'s checks Sprite. +pub(crate) async fn handshake( + State(state): State<AppState>, + Path(repo_path): Path<String>, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Response { + let segments: Vec<&str> = repo_path.split('/').filter(|s| !s.is_empty()).collect(); + let Some((repo, _rel, rest)) = super::resolve_repo(&state.data_dir, &segments) else { + return (StatusCode::NOT_FOUND, "no such repository").into_response(); + }; + if !rest.is_empty() { + return (StatusCode::NOT_FOUND, "no such repository").into_response(); + } + + let cookie = headers + .get(axum::http::header::COOKIE) + .and_then(|value| value.to_str().ok()); + let Some(session) = super::write::snapshot(&state.sessions, cookie) else { + return (StatusCode::UNAUTHORIZED, "sign in to open a debug session").into_response(); + }; + let store = match git_store::Store::open(&repo) { + Ok(store) => store, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("cannot open store: {e}"), + ) + .into_response(); + } + }; + if super::write::member_for_public_key_with(&store, &session.public_key).is_none() { + return ( + StatusCode::FORBIDDEN, + "your web key is not a member of this repository", + ) + .into_response(); + } + + let sprite = crate::checks::sprite_name(&repo); + let ready = tokio::task::spawn_blocking({ + let sprite = sprite.clone(); + move || crate::checks::ensure_auth().and_then(|()| crate::checks::ensure_sprite(&sprite)) + }) + .await; + if !matches!(ready, Ok(Ok(()))) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "could not prepare the sprite", + ) + .into_response(); + } + + ws.on_upgrade(move |socket| relay(socket, sprite)) +} + +/// Spawn an interactive shell in `sprite` and relay it over `socket` until +/// either side closes: the Sprite CLI's own `--tty` handles the pseudo-TTY, so +/// the broker only ever pumps bytes. +async fn relay(mut socket: WebSocket, sprite: String) { + let child = Command::new("sprite") + .args(["exec", "--tty", "-s", &sprite, "--", "/bin/bash"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + let mut child = match child { + Ok(child) => child, + Err(_could_not_spawn) => return, + }; + let (Some(mut stdin), Some(mut stdout)) = (child.stdin.take(), child.stdout.take()) else { + return; + }; + + let mut buf = [0u8; 4096]; + loop { + tokio::select! { + read = stdout.read(&mut buf) => { + match read { + Ok(0) | Err(_) => break, + Ok(n) => { + let Some(chunk) = buf.get(..n) else { break }; + if socket.send(Message::Binary(chunk.to_vec().into())).await.is_err() { + break; + } + } + } + } + frame = socket.recv() => { + match frame { + Some(Ok(Message::Binary(data))) => { + if stdin.write_all(&data).await.is_err() { + break; + } + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + } + } + } + } + let _killed = child.kill().await; +}
crates/git-ents/src/debug_session.rs @@ -1,0 +1,86 @@ +//! The CLI side of an interactive debug session: connect to the server's +//! WebSocket broker (see `git-ents-server`'s `web::debug`), put this +//! terminal into raw mode, and pump bytes between it and the remote shell +//! until either side closes. + +use std::io::{Read as _, Write as _}; + +use futures_util::{SinkExt as _, StreamExt as _}; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; + +/// Open a debug session at `url`, authenticated with the session `token` +/// stored by `git ents login`. +pub(crate) async fn run(url: &str, token: &str) -> Result<(), String> { + let mut request = url + .into_client_request() + .map_err(|error| format!("bad debug session URL: {error}"))?; + let cookie = format!("ents_session={token}") + .parse() + .map_err(|_invalid| "the stored session token is not a valid cookie value".to_owned())?; + request.headers_mut().insert("Cookie", cookie); + + let (stream, _response) = tokio_tungstenite::connect_async(request) + .await + .map_err(|error| format!("could not open the debug session: {error}"))?; + let (mut sink, mut source) = stream.split(); + + crossterm::terminal::enable_raw_mode() + .map_err(|error| format!("could not set the terminal to raw mode: {error}"))?; + let result = pump(&mut sink, &mut source).await; + let _restored = crossterm::terminal::disable_raw_mode(); + result +} + +/// Relay bytes both ways: a background thread feeds raw stdin bytes through +/// `tx`, forwarded here to the sink, while frames from `source` are written +/// straight to stdout. A dedicated thread reads stdin because raw terminal +/// input has no natural way to interrupt a blocking read when the session +/// ends from the other side. +async fn pump<S, R>(sink: &mut S, source: &mut R) -> Result<(), String> +where + S: futures_util::Sink<Message> + Unpin, + R: futures_util::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin, +{ + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>(); + std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + loop { + match std::io::stdin().read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let Some(chunk) = buf.get(..n) else { break }; + if tx.send(chunk.to_vec()).is_err() { + break; + } + } + } + } + }); + + loop { + tokio::select! { + input = rx.recv() => { + match input { + Some(bytes) => { + if sink.send(Message::Binary(bytes.into())).await.is_err() { + return Ok(()); + } + } + None => return Ok(()), + } + } + frame = source.next() => { + match frame { + Some(Ok(Message::Binary(data))) => { + let _write = std::io::stdout().write_all(&data); + let _flush = std::io::stdout().flush(); + } + Some(Ok(Message::Close(_))) | None => return Ok(()), + Some(Err(error)) => return Err(format!("debug session error: {error}")), + _ => {} + } + } + } + } +}