diff --git a/Cargo.lock b/Cargo.lock index 1384a22..faf7c8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -64,6 +73,39 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[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 2.0.118", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -71,25 +113,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "aws-lc-rs" -version = "1.17.0" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "aws-lc-sys" -version = "0.41.0" +name = "axum" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", ] [[package]] @@ -98,7 +151,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core", + "axum-core 0.5.6", "bytes", "form_urlencoded", "futures-util", @@ -108,7 +161,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -119,12 +172,32 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -150,6 +223,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[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.0" @@ -170,12 +249,16 @@ name = "brainpod-cli" version = "0.1.0" dependencies = [ "anyhow", - "axum", + "axum 0.8.9", "clap", "dirs", "flate2", "getrandom 0.3.4", + "prost", + "prost-types", + "protox", "reqwest", + "rustls", "serde", "serde_json", "sha2", @@ -185,6 +268,9 @@ dependencies = [ "tokio-stream", "tokio-util", "toml", + "tonic", + "tonic-build", + "uuid", "webbrowser", ] @@ -207,8 +293,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -224,12 +308,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "clap" version = "4.6.1" @@ -261,7 +339,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -270,15 +348,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -388,14 +457,14 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] -name = "dunce" -version = "1.0.5" +name = "either" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" @@ -444,6 +513,12 @@ 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 = "flate2" version = "1.1.9" @@ -469,12 +544,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures-channel" version = "0.3.32" @@ -504,7 +573,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -552,10 +621,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -565,11 +632,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", - "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", ] [[package]] @@ -584,13 +660,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "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.17.1" @@ -685,6 +767,19 @@ dependencies = [ "tower-service", ] +[[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]] name = "hyper-util" version = "0.1.20" @@ -702,7 +797,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -813,6 +908,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" @@ -820,7 +925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -835,6 +940,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[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" @@ -884,7 +998,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.118", ] [[package]] @@ -912,17 +1026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", + "syn 2.0.118", ] [[package]] @@ -936,6 +1040,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -970,10 +1080,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] -name = "lru-slab" -version = "0.1.2" +name = "logos" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +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 2.0.118", +] + +[[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 = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "matchit" @@ -987,6 +1130,28 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[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 2.0.118", +] + [[package]] name = "mime" version = "0.3.17" @@ -1014,6 +1179,12 @@ 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 = "ndk-context" version = "0.1.1" @@ -1075,6 +1246,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 2.0.118", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1099,6 +1300,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 2.0.118", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1109,59 +1320,95 @@ dependencies = [ ] [[package]] -name = "quinn" -version = "0.11.9" +name = "prost" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", + "prost-derive", ] [[package]] -name = "quinn-proto" -version = "0.11.14" +name = "prost-build" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.118", + "tempfile", ] [[package]] -name = "quinn-udp" -version = "0.5.14" +name = "prost-derive" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ - "cfg_aliases", - "libc", + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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", - "socket2", - "tracing", - "windows-sys 0.60.2", + "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]] @@ -1179,21 +1426,28 @@ 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" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", @@ -1201,11 +1455,11 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.2.17", ] [[package]] @@ -1219,6 +1473,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.13.4" @@ -1242,7 +1525,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -1253,7 +1535,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -1277,12 +1559,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - [[package]] name = "rustc_version" version = "0.4.1" @@ -1311,8 +1587,9 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1331,13 +1608,21 @@ dependencies = [ "security-framework", ] +[[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.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -1374,7 +1659,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1466,7 +1750,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1575,6 +1859,16 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[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.4" @@ -1614,6 +1908,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1631,7 +1936,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1705,7 +2010,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1716,7 +2021,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1729,21 +2034,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.52.3" @@ -1755,7 +2045,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -1768,7 +2058,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1811,7 +2101,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -1844,6 +2134,73 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[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 0.7.9", + "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", + "webpki-roots 0.26.11", +] + +[[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 2.0.118", +] + +[[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" @@ -1872,7 +2229,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "url", @@ -1898,9 +2255,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1928,6 +2297,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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" @@ -1958,6 +2333,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -2040,7 +2426,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2076,16 +2462,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webbrowser" version = "1.0.6" @@ -2111,6 +2487,24 @@ dependencies = [ "rustls-pki-types", ] +[[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.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -2173,15 +2567,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2215,30 +2600,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -2251,12 +2619,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -2269,12 +2631,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -2287,24 +2643,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -2317,12 +2661,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -2335,12 +2673,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -2353,12 +2685,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -2371,12 +2697,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -2430,7 +2750,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2451,7 +2771,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2471,7 +2791,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2511,7 +2831,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ae69172..2ea6afb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,14 +14,23 @@ clap = { version = "4.6.1", features = ["derive"] } dirs = "6.0.0" flate2 = "1.1.2" getrandom = "0.3.4" -reqwest = { version = "0.13.4", default-features = false, features = ["charset", "http2", "json", "query", "rustls", "stream", "system-proxy"] } +prost = "0.13.5" +prost-types = "0.13.5" +reqwest = { version = "0.13.4", default-features = false, features = ["charset", "http2", "json", "query", "rustls-no-provider", "stream", "system-proxy"] } +rustls = { version = "0.23.40", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.10.9" tar = "0.4.44" tempfile = "3.20.0" -tokio = { version = "1.52.3", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } +tokio = { version = "1.52.3", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-stream = "0.1.17" tokio-util = { version = "0.7.16", features = ["io"] } toml = "0.9.5" +tonic = { version = "0.12.3", features = ["tls", "tls-webpki-roots"] } +uuid = { version = "1.16.0", features = ["v4"] } webbrowser = "=1.0.6" + +[build-dependencies] +protox = "0.7.1" +tonic-build = "0.12.3" diff --git a/README.md b/README.md index ecee90f..f69a97d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Brainpod CLI -A non-interactive CLI for managing Brainpod pods, images, blueprints, revisions, resources, deployments, and events. Its default output is deterministic line-oriented text suitable for LLMs and shell tools. Add `--json` to receive machine-readable JSON; login and event watches use NDJSON. +A non-interactive CLI for managing Brainpod pods, images, blueprints, revisions, resources, deployments, database tunnels, and events. Its default output is deterministic line-oriented text suitable for LLMs and shell tools. Add `--json` to receive machine-readable JSON; login, database tunnels, and event watches use NDJSON. The CLI builds application images locally from an existing Dockerfile or with Railpack, then pushes them directly to the selected pod's private Brainpod registry namespace. Image builds probe the API's cluster architectures, prefer amd64 and then arm64, and store the selected default architecture in the configuration. Use `--platform linux/arm64` for a one-off override. @@ -95,6 +95,7 @@ The default configuration file is `~/.config/brainpod/config.toml`. `XDG_CONFIG_ brainpod config set api-token brain_example brainpod config set pod my-pod brainpod config set endpoint https://api.brainpod.io +brainpod config set control-plane-endpoint https://control-plane.brainpod.io brainpod config set registry-endpoint https://registry.brainpod.io brainpod config set architecture arm64 brainpod config show @@ -105,6 +106,7 @@ Configuration uses TOML: ```toml endpoint = "https://api.brainpod.io" +control_plane_endpoint = "https://control-plane.brainpod.io" registry_endpoint = "https://registry.brainpod.io" api_token = "brain_example" pod = "my-pod" @@ -113,10 +115,10 @@ architecture = "amd64" Values are resolved in this order: -1. Global flags: `--endpoint`, `--registry-endpoint`, `--api-token`, `--pod` -2. `BRAINPOD_API_ENDPOINT`, `BRAINPOD_REGISTRY_ENDPOINT`, `BRAINPOD_API_TOKEN`, `BRAINPOD_POD` +1. Global flags: `--endpoint`, `--control-plane-endpoint`, `--registry-endpoint`, `--api-token`, `--pod` +2. `BRAINPOD_API_ENDPOINT`, `BRAINPOD_CONTROL_PLANE_ENDPOINT`, `BRAINPOD_REGISTRY_ENDPOINT`, `BRAINPOD_API_TOKEN`, `BRAINPOD_POD` 3. The configuration file -4. The defaults `https://api.brainpod.io` and `https://registry.brainpod.io` +4. The defaults `https://api.brainpod.io`, `https://control-plane.brainpod.io`, and `https://registry.brainpod.io` For image builds, `--platform` overrides the configured architecture. Without it, the CLI probes the available clusters, prefers `amd64` and then `arm64`, and stores the selected architecture in the configuration. @@ -196,19 +198,45 @@ brainpod --pod resource replace --file brainpod --pod resource delete brainpod --pod resource variables [ ] [--revision | --at ] +brainpod --pod tunnel [] [--skip-preflight] + brainpod --pod deploy [--summary ] [--wait] [--timeout ] brainpod --pod redeploy -brainpod --pod events --resource [--kind ] \ +brainpod --pod events --resource [--kind ] \ [--level ] [--search ] \ [--range <5m|15m|30m|1h|24h|7d>] [--cursor ] -brainpod --pod events --watch --resource \ +brainpod --pod events --watch --resource \ [--kind ] [--level ] \ [--search ] [--range <5m|15m|30m|1h|24h|7d>] [--cursor ] \ [--duration <1-20>] [--last-event-id ] ``` -Events use the resource URN returned by resource list, get, or mutation responses, such as `urn:brain:app:default:api`. Omit `--kind` to return every stream available for that resource. `--level` requires `--kind app`. +`brainpod tunnel` creates a two-hour database tunnel session and forwards local TCP connections until Ctrl-C is pressed. Select the pod with `--pod`, `BRAINPOD_POD`, or the configured default, then identify a deployed PostgreSQL, MariaDB, Valkey, or Microsoft SQL Server resource by name, URN, or stable UUID. For example, `brainpod --pod my-pod tunnel db` resolves `db` through the API before opening the tunnel. The listener defaults to `127.0.0.1` and the engine's standard port; pass an explicit address such as `127.0.0.1:15432` to override it. By default, the command prints an engine-specific banner with the local-to-remote port mapping, credentials, client command, and DSN before it starts accepting connections, so GUI clients such as DBeaver can be configured first. Pass `--skip-preflight` to skip credential retrieval and omit the password and DSN. The API token must grant `resources:read` and `database:connect` for the database or its pod. + +```text +╭─ ◆ Brainpod tunnel +│ +│ PostgreSQL +│ Local 127.0.0.1:15432 +│ Remote PostgreSQL:5432 +│ +│ Username brainpod +│ Database brainpod +│ Password ... +│ +├─ Client +│ psql "host=127.0.0.1 port=15432 user=brainpod dbname=brainpod sslmode=require" +│ +├─ DSN +│ postgres://brainpod:...@127.0.0.1:15432/brainpod?sslmode=require +│ +╰─ ● Ready · press Ctrl+C to stop +``` + +New, closed, and failed local connections are reported as concise status lines while the tunnel is running. + +Events accept a resource name, URN, or stable UUID. For example, `brainpod --pod my-pod events --resource api` resolves `api` to its canonical URN before querying events. Passing a URN directly skips resolution, so an API token with only `events:read` remains sufficient; resolving a name or UUID also requires `resources:read`. Omit `--kind` to return every stream available for the resource. `--level` requires `--kind app`. Event watches flush text or JSON output as messages arrive and reconnect after each server-imposed stream duration, continuing until interrupted. The per-request duration defaults to 10 seconds. Reconnects use the latest SSE event ID to avoid replaying emitted events. Use `--last-event-id` to set the initial event ID; `--cursor` resumes the initial request from an API event cursor. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..4714397 --- /dev/null +++ b/build.rs @@ -0,0 +1,16 @@ +fn main() -> Result<(), Box> { + let descriptors = protox::compile( + [ + "proto/brainpod/tunnel/v1/broker.proto", + "proto/brainpod/tunnel/v1/tunnel.proto", + ], + ["proto"], + )?; + + tonic_build::configure() + .build_server(false) + .build_transport(false) + .compile_fds(descriptors)?; + + Ok(()) +} diff --git a/flake.nix b/flake.nix index 0f03006..5747a6a 100644 --- a/flake.nix +++ b/flake.nix @@ -73,6 +73,9 @@ in relative == "Cargo.toml" || relative == "Cargo.lock" + || relative == "build.rs" + || relative == "proto" + || pkgs.lib.hasPrefix "proto/" relative || relative == "src" || pkgs.lib.hasPrefix "src/" relative; }; diff --git a/proto/brainpod/tunnel/v1/broker.proto b/proto/brainpod/tunnel/v1/broker.proto new file mode 100644 index 0000000..5f12680 --- /dev/null +++ b/proto/brainpod/tunnel/v1/broker.proto @@ -0,0 +1,44 @@ +syntax = "proto3"; + +package brainpod.tunnel.v1; + +import "google/protobuf/timestamp.proto"; + +// Client-facing control-plane API. Calls are authenticated with the client's +// control-plane credentials, not with a tunnel ticket. +service TunnelBroker { + rpc OpenSession(OpenSessionRequest) returns (OpenSessionResponse); + // Prevents new connections. Existing connections keep their fixed deadline. + rpc CloseSession(CloseSessionRequest) returns (CloseSessionResponse); +} + +message OpenSessionRequest { + string database_id = 1; + // Client-generated UUID used to make retries idempotent for these credentials. + string request_id = 2; +} + +message OpenSessionResponse { + // URI of the endpoint implementing TunnelService. + string endpoint = 1; + // Bearer capability reusable for connections until the session expires. + string ticket = 2; + string session_id = 3; + // Fixed deadline. Existing connections are closed when it is reached. + google.protobuf.Timestamp expires_at = 4; + DatabaseEngine engine = 5; +} + +message CloseSessionRequest { + string session_id = 1; +} + +message CloseSessionResponse {} + +enum DatabaseEngine { + DATABASE_ENGINE_UNSPECIFIED = 0; + DATABASE_ENGINE_POSTGRES = 1; + DATABASE_ENGINE_MARIADB = 2; + DATABASE_ENGINE_VALKEY = 3; + DATABASE_ENGINE_MSSQL = 4; +} diff --git a/proto/brainpod/tunnel/v1/tunnel.proto b/proto/brainpod/tunnel/v1/tunnel.proto new file mode 100644 index 0000000..c535917 --- /dev/null +++ b/proto/brainpod/tunnel/v1/tunnel.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package brainpod.tunnel.v1; + +// Relays one TCP connection. The client presents its opaque ticket using +// `authorization: Bearer ` gRPC metadata for broker authorization. +service TunnelService { + rpc Open(stream Chunk) returns (stream Chunk); +} + +message Chunk { + // Chunks must contain between 1 byte and 64 KiB. + bytes data = 1; +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 3570f86..ded8957 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -74,7 +74,10 @@ fn chat_id() -> Option { fn slug(chat: &str) -> String { let digest = Sha256::digest(chat.as_bytes()); - digest[..6].iter().map(|byte| format!("{byte:02x}")).collect() + digest[..6] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } #[derive(Debug, Args)] @@ -1016,7 +1019,9 @@ fn locate(root: &Path, chat: Option<&str>, ancestry: &[Owner]) -> Result return Ok(path.clone()), (Some(_), Some(_)) => { @@ -1166,8 +1171,8 @@ pub fn render_clear(value: &Value) -> Vec { #[cfg(test)] mod tests { use super::{ - covers_console, locate, pod_url, slug, Owner, Session, Step, DIRECTORY, LOG_FILE, SCHEMA, - SESSION_FILE, + DIRECTORY, LOG_FILE, Owner, SCHEMA, SESSION_FILE, Session, Step, covers_console, locate, + pod_url, slug, }; use std::fs; use std::path::Path; @@ -1224,7 +1229,9 @@ mod tests { plant(root.path(), &slug("chat-a"), "running", &[owner(7)], 1); plant(root.path(), &slug("chat-b"), "running", &[owner(7)], 2); - let error = locate(root.path(), None, &[owner(7)]).unwrap_err().to_string(); + let error = locate(root.path(), None, &[owner(7)]) + .unwrap_err() + .to_string(); assert!(error.contains("--session"), "{error}"); } @@ -1243,7 +1250,11 @@ mod tests { let root = TempDir::new().unwrap(); let flat = root.path().join(DIRECTORY); fs::create_dir_all(&flat).unwrap(); - fs::write(flat.join(SESSION_FILE), b"{\"schema\":2,\"session\":\"old\"}").unwrap(); + fs::write( + flat.join(SESSION_FILE), + b"{\"schema\":2,\"session\":\"old\"}", + ) + .unwrap(); fs::write(flat.join(LOG_FILE), b"").unwrap(); assert_eq!(locate(root.path(), Some("chat-a"), &[]).unwrap(), flat); @@ -1348,7 +1359,11 @@ mod tests { } fn states(session: &Session) -> Vec<&str> { - session.steps.iter().map(|step| step.state.as_str()).collect() + session + .steps + .iter() + .map(|step| step.state.as_str()) + .collect() } #[test] diff --git a/src/auth.rs b/src/auth.rs index e3b5817..11d5895 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -407,7 +407,8 @@ fn escape(text: &str) -> String { /// advertises, so the session file routinely outlives the server that wrote it. async fn handover() -> Option { let url = crate::agent::console_url()?; - let http = reqwest::Client::builder() + let http = crate::http_client_builder() + .ok()? .timeout(HANDOVER_PROBE) .build() .ok()?; diff --git a/src/client/mod.rs b/src/client/mod.rs index a1e06e9..5ca81ed 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -3,6 +3,7 @@ use std::fmt; use anyhow::{Context, Result, anyhow}; use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderValue}; use reqwest::{Method, Response, StatusCode, Url}; +use serde::Deserialize; use serde_json::Value; #[derive(Clone)] @@ -32,6 +33,12 @@ pub struct EventStreamMessage { pub data: String, } +#[derive(Deserialize)] +pub struct ResourceIdentity { + pub urn: String, + pub uuid: String, +} + impl Client { pub fn try_new(endpoint: &str, api_token: &str) -> Result { if api_token.trim().is_empty() { @@ -43,7 +50,7 @@ impl Client { let authorization = reqwest::header::HeaderValue::from_str(&format!("Bearer {api_token}")) .context("API token contains invalid header characters")?; headers.insert(reqwest::header::AUTHORIZATION, authorization); - let http = reqwest::Client::builder() + let http = crate::http_client_builder()? .default_headers(headers) .build() .context("failed to create HTTP client")?; @@ -55,6 +62,17 @@ impl Client { self.request(Method::GET, path, query, None).await } + pub async fn resolve_resource(&self, pod: &str, identifier: &str) -> Result { + let value = self + .get( + &["v1", "pods", pod, "resources", "resolve", identifier], + &[], + ) + .await + .with_context(|| format!("failed to resolve resource `{identifier}` in pod `{pod}`"))?; + serde_json::from_value(value).context("Brainpod API returned an invalid resource identity") + } + pub async fn post( &self, path: &[&str], diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index be28e92..4877aa4 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -35,6 +35,8 @@ pub enum Command { Image(ImageArgs), /// Inspect pod revisions Revision(RevisionArgs), + /// Create a local TCP tunnel to a deployed database + Tunnel(TunnelArgs), /// Create and manage pod resources Resource(ResourceArgs), /// Deploy the current draft revision @@ -59,6 +61,17 @@ pub struct LoginArgs { pub no_browser: bool, } +#[derive(Debug, Args)] +pub struct TunnelArgs { + /// Database resource name, URN, or stable UUID + pub resource: String, + /// Local IP address and port; defaults to loopback and the database engine's standard port + pub listen_address: Option, + /// Skip database credential preflight and password output + #[arg(long)] + pub skip_preflight: bool, +} + #[derive(Debug, Args)] pub struct ConfigArgs { #[command(subcommand)] @@ -80,6 +93,7 @@ enum ConfigCommand { #[derive(Clone, Debug, ValueEnum)] enum ConfigKey { Endpoint, + ControlPlaneEndpoint, RegistryEndpoint, ApiToken, Pod, @@ -364,8 +378,8 @@ pub struct EventsArgs { /// Event stream; omit to return all streams for the resource #[arg(long)] kind: Option, - /// Event-capable resource URN - #[arg(long, value_name = "URN", value_parser = parse_event_resource_urn)] + /// Event-capable resource name, URN, or stable UUID + #[arg(long, value_name = "RESOURCE")] resource: String, /// Filter app events by level #[arg(long, requires = "kind")] @@ -458,22 +472,19 @@ impl EventRange { } } -fn parse_event_resource_urn(value: &str) -> std::result::Result { +fn is_event_resource_urn(value: &str) -> bool { let mut parts = value.split(':'); - let valid = parts.next() == Some("urn") + parts.next() == Some("urn") && parts.next() == Some("brain") && parts.next().is_some_and(|kind| { - matches!(kind, "app" | "postgres" | "mariadb" | "valkey" | "route") + matches!( + kind, + "app" | "postgres" | "mariadb" | "valkey" | "mssql" | "route" + ) }) && parts.next() == Some("default") && parts.next().is_some_and(|name| !name.is_empty()) - && parts.next().is_none(); - - if valid { - Ok(value.to_owned()) - } else { - Err("must match urn:brain::default:".to_owned()) - } + && parts.next().is_none() } pub fn needs_api_token(command: &Command) -> bool { @@ -486,26 +497,38 @@ pub fn needs_api_token(command: &Command) -> bool { pub fn needs_client(command: &Command) -> bool { !matches!( command, - Command::Describe(_) - | Command::Agent(_) - | Command::Login(_) - | Command::Config(_) + Command::Describe(_) | Command::Agent(_) | Command::Login(_) | Command::Config(_) ) } -pub async fn handle( - command: Command, - client: Option<&Client>, - endpoint: &str, - dashboard_endpoint: &str, - pod: Option<&str>, - api_token: Option<&str>, - registry_endpoint: &str, - config: &mut Config, - config_path: &Path, - show_progress: bool, - json: bool, -) -> Result { +pub struct CommandContext<'a> { + pub client: Option<&'a Client>, + pub endpoint: &'a str, + pub control_plane_endpoint: &'a str, + pub dashboard_endpoint: &'a str, + pub pod: Option<&'a str>, + pub api_token: Option<&'a str>, + pub registry_endpoint: &'a str, + pub config: &'a mut Config, + pub config_path: &'a Path, + pub show_progress: bool, + pub json: bool, +} + +pub async fn handle(command: Command, context: CommandContext<'_>) -> Result { + let CommandContext { + client, + endpoint, + control_plane_endpoint, + dashboard_endpoint, + pod, + api_token, + registry_endpoint, + config, + config_path, + show_progress, + json, + } = context; match command { Command::Describe(args) => Ok(CommandOutput::new( crate::describe::generate(crate::Opts::command(), &args.command)?, @@ -533,9 +556,18 @@ pub async fn handle( )), Command::Cluster(args) => handle_cluster(client_required(client)?, args).await, Command::Pod(args) => handle_pod(client_required(client)?, args).await, - Command::Blueprint(args) => { - handle_blueprint(client_required(client)?, pod, args).await + Command::Tunnel(args) => { + crate::tunnel::handle( + client_required(client)?, + pod_required(pod)?, + args, + control_plane_endpoint, + api_token.ok_or_else(|| anyhow!("API token is required"))?, + json, + ) + .await } + Command::Blueprint(args) => handle_blueprint(client_required(client)?, pod, args).await, Command::Image(args) => { handle_image( client, @@ -618,6 +650,7 @@ fn handle_config(args: ConfigArgs, config: &mut Config, path: &Path) -> Result Result { + config.control_plane_endpoint = Some(value); + "controlPlaneEndpoint" + } ConfigKey::RegistryEndpoint => { config.registry_endpoint = Some(value); "registryEndpoint" @@ -667,6 +704,10 @@ fn handle_config(args: ConfigArgs, config: &mut Config, path: &Path) -> Result { + config.control_plane_endpoint = None; + "controlPlaneEndpoint" + } ConfigKey::RegistryEndpoint => { config.registry_endpoint = None; "registryEndpoint" @@ -862,10 +903,7 @@ async fn handle_image( limit, offset, } => { - let mut query = vec![ - ("limit", limit.to_string()), - ("offset", offset.to_string()), - ]; + let mut query = vec![("limit", limit.to_string()), ("offset", offset.to_string())]; push_query(&mut query, "search", search); push_query( &mut query, @@ -904,25 +942,20 @@ async fn handle_image( platform, output, } => { - let platform = resolve_platform( - client_required(client)?, - platform, - config, - config_path, - ) - .await?; + let platform = + resolve_platform(client_required(client)?, platform, config, config_path).await?; crate::agent::note("image", "Packaging your app", "running", None); - let built = crate::image::build( + let built = crate::image::build(crate::image::BuildRequest { image, context, tag, - builder, + method: builder, output, platform, pod, api_token, registry_endpoint, - ) + }) .await; match &built { Ok(value) => crate::agent::note( @@ -1252,8 +1285,13 @@ async fn handle_events(client: &Client, pod: &str, args: EventsArgs) -> Result, + pub control_plane_endpoint: Option, pub registry_endpoint: Option, pub api_token: Option, pub pod: Option, diff --git a/src/describe.rs b/src/describe.rs index 4189680..bb6796c 100644 --- a/src/describe.rs +++ b/src/describe.rs @@ -36,16 +36,18 @@ pub fn generate(mut root: Command, path: &[String]) -> Result { "json": "Pass --json to emit the complete API response as one JSON value for non-streaming commands.", "loginJson": "Login emits an authorize event followed by an authenticated event as NDJSON on stdout.", "eventWatchJson": "Event watches emit one JSON value per line as NDJSON.", + "tunnelJson": "Tunnels emit listening, optional credentials, and closed events as NDJSON.", "waitProgress": "Interactive waits report unhealthy-to-healthy transitions on stderr; progress is suppressed when stderr is redirected or --json is used.", "errors": "Errors are written to stderr and return a non-zero exit code; --json also makes errors JSON." }, "guidance": [ - "Use --json for complete machine-readable API responses and errors; login and event watches are streamed as NDJSON.", + "Use --json for complete machine-readable API responses and errors; login, event watches, and tunnels are streamed as NDJSON.", "Pod-scoped commands require --pod, BRAINPOD_POD, or a configured default pod.", + "Database tunnels resolve a resource name, URN, or stable UUID in the selected pod and listen on loopback by default until Ctrl-C is pressed.", "Image builds prefer an existing Dockerfile, otherwise use Railpack, target the best architecture supported by the API (override with --platform), and push to the selected pod's private registry namespace.", "Blueprint installation and resource mutations update the mutable draft; run deploy separately when ready, optionally with --wait.", "Use blueprint get to inspect blueprint documentation, defaults, and its input schema before installation.", - "Use resource URNs returned by resource commands when querying events.", + "Event queries accept a resource name, URN, or stable UUID in the selected pod.", "Use `brainpod describe resource ` to inspect the resource schema and the variables that kind exports, fetched from the production OpenAPI document; the embedded document is used when it is unavailable. It needs no pod and no API token, so run it before creating anything.", "Use `brainpod --pod resource variables` to read the resolved references of resources that already exist; secret values are never returned." ] @@ -141,7 +143,11 @@ fn describe_argument(command: &Command, argument: &Arg) -> Value { .get_value_names() .map(|names| names.iter().map(ToString::to_string).collect::>()) .unwrap_or_else(|| { - if takes_value { vec![argument.get_id().as_str().to_ascii_uppercase()] } else { Default::default() } + if takes_value { + vec![argument.get_id().as_str().to_ascii_uppercase()] + } else { + Default::default() + } }); let defaults = argument .get_default_values() @@ -172,7 +178,10 @@ fn describe_argument(command: &Command, argument: &Arg) -> Value { let mut value = Map::new(); value.insert("id".to_owned(), json!(argument.get_id().as_str())); - value.insert("syntax".to_owned(), json!(argument_syntax(argument, &value_names))); + value.insert( + "syntax".to_owned(), + json!(argument_syntax(argument, &value_names)), + ); value.insert( "kind".to_owned(), json!(if argument.get_index().is_some() { @@ -188,10 +197,12 @@ fn describe_argument(command: &Command, argument: &Arg) -> Value { value.insert("numValues".to_owned(), json!(num_values)); value.insert( "help".to_owned(), - json!(argument - .get_long_help() - .or_else(|| argument.get_help()) - .map(ToString::to_string)), + json!( + argument + .get_long_help() + .or_else(|| argument.get_help()) + .map(ToString::to_string) + ), ); value.insert("defaultValues".to_owned(), json!(defaults)); value.insert("possibleValues".to_owned(), json!(possible_values)); @@ -237,18 +248,16 @@ fn requirements(path: &[&str]) -> (Option, Option) { ["describe"] | ["login"] | ["config"] | ["config", _] => (Some(false), Some(false)), ["blueprint"] => (Some(true), None), ["blueprint", "install"] => (Some(true), Some(true)), - ["blueprint", _] - | ["whoami"] - | ["cluster"] - | ["cluster", _] - | ["pod"] - | ["pod", _] => (Some(true), Some(false)), + ["blueprint", _] | ["whoami"] | ["cluster"] | ["cluster", _] | ["pod"] | ["pod", _] => { + (Some(true), Some(false)) + } ["image"] | ["image", _] | ["revision"] | ["revision", _] | ["resource"] | ["resource", _] + | ["tunnel"] | ["deploy"] | ["redeploy"] | ["events"] => (Some(true), Some(true)), @@ -258,9 +267,10 @@ fn requirements(path: &[&str]) -> (Option, Option) { fn effect(path: &[&str]) -> (&'static str, &'static str) { match path { - ["describe"] | ["config", "show"] | ["config", "path"] => { - ("read", "Reads local CLI information without changing state.") - } + ["describe"] | ["config", "show"] | ["config", "path"] => ( + "read", + "Reads local CLI information without changing state.", + ), ["config", "set"] | ["config", "unset"] => { ("local-write", "Changes the local CLI configuration.") } @@ -278,7 +288,14 @@ fn effect(path: &[&str]) -> (&'static str, &'static str) { | ["resource", "list"] | ["resource", "get"] | ["resource", "variables"] => ("read", "Reads remote state without changing it."), - ["events"] => ("read-or-stream", "Reads or continuously streams remote events."), + ["events"] => ( + "read-or-stream", + "Reads or continuously streams remote events.", + ), + ["tunnel"] => ( + "local-and-remote-stream", + "Creates a remote tunnel session and forwards local TCP connections until Ctrl-C.", + ), ["image", "list"] | ["image", "inspect"] => ( "read", "Reads active registry images visible from the selected pod.", @@ -305,15 +322,15 @@ fn effect(path: &[&str]) -> (&'static str, &'static str) { fn next_steps(path: &[&str]) -> Vec<&'static str> { match path { ["login"] => vec!["Confirm the authenticated identity with `brainpod whoami`."], - ["pod", "create"] => vec![ - "Select the new pod with --pod or `brainpod config set pod `.", - ], + ["pod", "create"] => { + vec!["Select the new pod with --pod or `brainpod config set pod `."] + } ["image", "list"] => vec![ "Inspect an exact pod image with `brainpod image inspect `; use --visibility public for a public image.", ], - ["image", "inspect"] => vec![ - "Use a returned digest-pinned variant reference as an App resource's spec.image.", - ], + ["image", "inspect"] => { + vec!["Use a returned digest-pinned variant reference as an App resource's spec.image."] + } ["image", "build"] => vec![ "Use the returned digest-pinned reference as an App resource's spec.image.", "Deploy the updated App resource with `brainpod deploy` when ready.", @@ -328,13 +345,17 @@ fn next_steps(path: &[&str]) -> Vec<&'static str> { "Deploy it with `brainpod deploy` when ready.", ], ["resource", "list"] | ["resource", "get"] => vec![ - "Use a returned resource URN with `brainpod events --resource `.", + "Use the resource name or returned URN with `brainpod events --resource `.", "Read the resolved references with `brainpod resource variables`.", ], ["resource", "variables"] => vec![ "Reference a variable from an App resource's spec.env as its `ref` value, such as ${db.uri}.", "Secret values are never returned; reference them instead of reading them.", ], + ["tunnel"] => vec![ + "Keep the tunnel running while the local database client is connected.", + "Press Ctrl-C to close the tunnel session.", + ], _ => Vec::new(), } } @@ -354,6 +375,10 @@ fn examples(path: &[&str]) -> Vec<&'static str> { "brainpod config set pod my-pod", ], ["pod", "list"] => vec!["brainpod pod list --json"], + ["tunnel"] => vec![ + "brainpod --pod my-pod tunnel db", + "brainpod --pod my-pod tunnel urn:brain:postgres:default:db 127.0.0.1:15432", + ], ["blueprint", "get"] => vec!["brainpod blueprint get laravel"], ["blueprint", "install"] => vec![ "brainpod --pod my-pod blueprint install laravel", @@ -387,7 +412,7 @@ fn examples(path: &[&str]) -> Vec<&'static str> { "brainpod --pod my-pod deploy --summary \"Configure application resources\" --wait --json", ], ["events"] => vec![ - "brainpod --pod my-pod events --resource urn:brain:app:default:api", + "brainpod --pod my-pod events --resource api", "brainpod --pod my-pod events --watch --resource urn:brain:app:default:api --json", ], _ => Vec::new(), @@ -406,15 +431,21 @@ mod tests { let value = generate(crate::Opts::command(), &path).unwrap(); assert_eq!( - value.pointer("/command/invocation").and_then(|value| value.as_str()), + value + .pointer("/command/invocation") + .and_then(|value| value.as_str()), Some("brainpod resource create") ); assert_eq!( - value.pointer("/command/requirements/pod").and_then(|value| value.as_bool()), + value + .pointer("/command/requirements/pod") + .and_then(|value| value.as_bool()), Some(true) ); assert_eq!( - value.pointer("/command/effect").and_then(|value| value.as_str()), + value + .pointer("/command/effect") + .and_then(|value| value.as_str()), Some("conditional-draft-write") ); } @@ -425,15 +456,21 @@ mod tests { let value = generate(crate::Opts::command(), &path).unwrap(); assert_eq!( - value.pointer("/command/requirements/apiToken").and_then(|value| value.as_bool()), + value + .pointer("/command/requirements/apiToken") + .and_then(|value| value.as_bool()), Some(true) ); assert_eq!( - value.pointer("/command/requirements/pod").and_then(|value| value.as_bool()), + value + .pointer("/command/requirements/pod") + .and_then(|value| value.as_bool()), Some(true) ); assert_eq!( - value.pointer("/command/effect").and_then(|value| value.as_str()), + value + .pointer("/command/effect") + .and_then(|value| value.as_str()), Some("local-and-remote-write") ); } @@ -444,15 +481,21 @@ mod tests { let value = generate(crate::Opts::command(), &path).unwrap(); assert_eq!( - value.pointer("/command/requirements/apiToken").and_then(|value| value.as_bool()), + value + .pointer("/command/requirements/apiToken") + .and_then(|value| value.as_bool()), Some(true) ); assert_eq!( - value.pointer("/command/requirements/pod").and_then(|value| value.as_bool()), + value + .pointer("/command/requirements/pod") + .and_then(|value| value.as_bool()), Some(true) ); assert_eq!( - value.pointer("/command/effect").and_then(|value| value.as_str()), + value + .pointer("/command/effect") + .and_then(|value| value.as_str()), Some("read") ); } @@ -463,25 +506,35 @@ mod tests { let value = generate(crate::Opts::command(), &path).unwrap(); assert_eq!( - value.pointer("/command/effect").and_then(|value| value.as_str()), + value + .pointer("/command/effect") + .and_then(|value| value.as_str()), Some("read") ); assert_eq!( - value.pointer("/command/requirements/apiToken").and_then(|value| value.as_bool()), + value + .pointer("/command/requirements/apiToken") + .and_then(|value| value.as_bool()), Some(true) ); assert_eq!( - value.pointer("/command/requirements/pod").and_then(|value| value.as_bool()), + value + .pointer("/command/requirements/pod") + .and_then(|value| value.as_bool()), Some(true) ); - assert!(!value - .pointer("/command/examples") - .and_then(|value| value.as_array()) - .is_none_or(Vec::is_empty)); - assert!(!value - .pointer("/command/nextSteps") - .and_then(|value| value.as_array()) - .is_none_or(Vec::is_empty)); + assert!( + !value + .pointer("/command/examples") + .and_then(|value| value.as_array()) + .is_none_or(Vec::is_empty) + ); + assert!( + !value + .pointer("/command/nextSteps") + .and_then(|value| value.as_array()) + .is_none_or(Vec::is_empty) + ); } #[test] @@ -496,12 +549,16 @@ mod tests { .filter_map(|step| step.as_str()) .collect::>(); - assert!(next_steps - .iter() - .any(|step| step.contains("brainpod resource variables"))); - assert!(next_steps - .iter() - .any(|step| step.contains("brainpod describe resource "))); + assert!( + next_steps + .iter() + .any(|step| step.contains("brainpod resource variables")) + ); + assert!( + next_steps + .iter() + .any(|step| step.contains("brainpod describe resource ")) + ); } #[test] diff --git a/src/image/mod.rs b/src/image/mod.rs index 732d3a9..4feadc4 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -110,17 +110,30 @@ pub(super) struct ImageLayout { runtime_user: Option, } -pub async fn build( - image: String, - context: PathBuf, - tag: String, - method: BuildMethod, - output: Option, - platform: String, - pod: &str, - api_token: &str, - registry_endpoint: &str, -) -> Result { +pub struct BuildRequest<'a> { + pub image: String, + pub context: PathBuf, + pub tag: String, + pub method: BuildMethod, + pub output: Option, + pub platform: String, + pub pod: &'a str, + pub api_token: &'a str, + pub registry_endpoint: &'a str, +} + +pub async fn build(request: BuildRequest<'_>) -> Result { + let BuildRequest { + image, + context, + tag, + method, + output, + platform, + pod, + api_token, + registry_endpoint, + } = request; validate_repository(&image)?; validate_namespace(pod)?; validate_tag(&tag)?; @@ -604,7 +617,7 @@ async fn railpack_binary() -> Result { "https://github.com/railwayapp/railpack/releases/download/{RAILPACK_VERSION}/{archive_name}" ); eprintln!("Downloading Railpack {RAILPACK_VERSION}"); - let http = reqwest::Client::builder() + let http = crate::http_client_builder()? .timeout(NETWORK_TIMEOUT) .build() .context("failed to create Railpack download client")?; diff --git a/src/image/registry.rs b/src/image/registry.rs index b28b39c..3a8ad1e 100644 --- a/src/image/registry.rs +++ b/src/image/registry.rs @@ -43,7 +43,7 @@ impl<'a> Registry<'a> { api_token, authority, base, - http: Client::builder() + http: crate::http_client_builder()? .timeout(NETWORK_TIMEOUT) .build() .context("failed to create registry client")?, @@ -219,6 +219,11 @@ async fn expect_status(response: Response, expected: &[StatusCode]) -> Result &str { + let digest = digest.strip_prefix("sha256:").unwrap_or(digest); + &digest[..digest.len().min(12)] +} + #[cfg(test)] mod tests { use reqwest::Url; @@ -242,8 +247,3 @@ mod tests { assert!(registry.validate_upload_url(&other).is_err()); } } - -fn short(digest: &str) -> &str { - let digest = digest.strip_prefix("sha256:").unwrap_or(digest); - &digest[..digest.len().min(12)] -} diff --git a/src/main.rs b/src/main.rs index 3de4ea4..630be05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use std::io::{self, IsTerminal as _}; use std::process::ExitCode; +use std::sync::OnceLock; use anyhow::{Result, anyhow}; use clap::{CommandFactory as _, Parser}; @@ -14,10 +15,14 @@ mod describe; mod image; mod openapi; mod output; +mod tunnel; use client::{ApiError, Client}; use cmd::Command; -use config::{Config, DEFAULT_DASHBOARD_ENDPOINT, DEFAULT_ENDPOINT, DEFAULT_REGISTRY_ENDPOINT}; +use config::{ + Config, DEFAULT_CONTROL_PLANE_ENDPOINT, DEFAULT_DASHBOARD_ENDPOINT, DEFAULT_ENDPOINT, + DEFAULT_REGISTRY_ENDPOINT, +}; const UPGRADE_URL: &str = "https://console.brainpod.io/onboarding?upgrade=1"; @@ -25,7 +30,7 @@ const UPGRADE_URL: &str = "https://console.brainpod.io/onboarding?upgrade=1"; #[command( name = "brainpod", version, - about = "Manage Brainpod deployments, images, and resources", + about = "Manage Brainpod deployments, images, resources, and database tunnels", after_help = "For machine-readable command metadata, run `brainpod describe --json`." )] pub(crate) struct Opts { @@ -41,6 +46,10 @@ pub(crate) struct Opts { #[arg(long, global = true)] api_token: Option, + /// Brainpod control-plane gRPC endpoint (overrides environment and config) + #[arg(long, global = true)] + control_plane_endpoint: Option, + /// Brainpod registry endpoint (overrides environment and config) #[arg(long, global = true)] registry_endpoint: Option, @@ -61,6 +70,10 @@ pub(crate) struct Opts { async fn main() -> ExitCode { let opts = Opts::parse(); let json_output = opts.json; + if let Err(error) = install_crypto_provider() { + write_error(&error, json_output); + return ExitCode::FAILURE; + } agent::configure(opts.session.clone()); match run(opts).await { @@ -127,6 +140,11 @@ async fn run(opts: Opts) -> Result { .api_token .or_else(|| environment("BRAINPOD_API_TOKEN")) .or_else(|| config.api_token.clone()); + let control_plane_endpoint = opts + .control_plane_endpoint + .or_else(|| environment("BRAINPOD_CONTROL_PLANE_ENDPOINT")) + .or_else(|| config.control_plane_endpoint.clone()) + .unwrap_or_else(|| DEFAULT_CONTROL_PLANE_ENDPOINT.to_owned()); let dashboard_endpoint = environment("BRAINPOD_DASHBOARD_ENDPOINT") .unwrap_or_else(|| DEFAULT_DASHBOARD_ENDPOINT.to_owned()); let registry_endpoint = opts @@ -163,20 +181,45 @@ async fn run(opts: Opts) -> Result { cmd::handle( opts.command, - client.as_ref(), - &endpoint, - &dashboard_endpoint, - pod.as_deref(), - api_token.as_deref(), - ®istry_endpoint, - &mut config, - &config_path, - show_progress, - json, + cmd::CommandContext { + client: client.as_ref(), + endpoint: &endpoint, + control_plane_endpoint: &control_plane_endpoint, + dashboard_endpoint: &dashboard_endpoint, + pod: pod.as_deref(), + api_token: api_token.as_deref(), + registry_endpoint: ®istry_endpoint, + config: &mut config, + config_path: &config_path, + show_progress, + json, + }, ) .await } +fn install_crypto_provider() -> Result<()> { + static INSTALL: OnceLock> = OnceLock::new(); + let installed = INSTALL.get_or_init(|| { + if rustls::crypto::CryptoProvider::get_default().is_some() { + Ok(()) + } else { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| ()) + } + }); + installed + .as_ref() + .map(|_| ()) + .map_err(|_| anyhow!("failed to install the rustls ring crypto provider")) +} + +pub(crate) fn http_client_builder() -> Result { + install_crypto_provider()?; + Ok(reqwest::Client::builder()) +} + fn environment(name: &str) -> Option { std::env::var(name) .ok() @@ -317,25 +360,15 @@ mod tests { #[test] fn parses_api_token() { - let opts = Opts::try_parse_from([ - "brainpod", - "--api-token", - "brain_example", - "whoami", - ]) - .unwrap(); + let opts = + Opts::try_parse_from(["brainpod", "--api-token", "brain_example", "whoami"]).unwrap(); assert_eq!(opts.api_token.as_deref(), Some("brain_example")); } #[test] fn rejects_api_key_flag() { - let result = Opts::try_parse_from([ - "brainpod", - "--api-key", - "brain_example", - "whoami", - ]); + let result = Opts::try_parse_from(["brainpod", "--api-key", "brain_example", "whoami"]); assert!(result.is_err()); } @@ -348,6 +381,29 @@ mod tests { assert!(super::cmd::needs_client(&opts.command)); } + #[test] + fn parses_database_tunnel() { + let opts = Opts::try_parse_from([ + "brainpod", + "--pod", + "my-pod", + "tunnel", + "db", + "127.0.0.1:15432", + "--skip-preflight", + ]) + .unwrap(); + + let Command::Tunnel(args) = &opts.command else { + panic!("expected tunnel command"); + }; + assert_eq!(args.resource, "db"); + assert_eq!(args.listen_address.unwrap().port(), 15432); + assert!(args.skip_preflight); + assert!(super::cmd::needs_client(&opts.command)); + assert!(super::cmd::needs_api_token(&opts.command)); + } + #[test] fn parses_image_build() { let opts = Opts::try_parse_from([ @@ -412,21 +468,14 @@ mod tests { #[test] fn parses_image_inspect_with_default_visibility() { - let opts = Opts::try_parse_from(["brainpod", "image", "inspect", "api", "latest"]) - .unwrap(); + let opts = Opts::try_parse_from(["brainpod", "image", "inspect", "api", "latest"]).unwrap(); assert!(matches!(opts.command, Command::Image(_))); } #[test] fn rejects_invalid_image_list_limit() { - let result = Opts::try_parse_from([ - "brainpod", - "image", - "list", - "--limit", - "101", - ]); + let result = Opts::try_parse_from(["brainpod", "image", "list", "--limit", "101"]); assert!(result.is_err()); } @@ -480,10 +529,10 @@ mod tests { } #[test] - fn rejects_invalid_event_resource_urn() { - let result = Opts::try_parse_from(["brainpod", "events", "--resource", "api"]); + fn accepts_event_resource_name() { + let opts = Opts::try_parse_from(["brainpod", "events", "--resource", "api"]).unwrap(); - assert!(result.is_err()); + assert!(matches!(opts.command, Command::Events(_))); } #[test] diff --git a/src/openapi.rs b/src/openapi.rs index 986003d..ae50121 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -65,9 +65,10 @@ pub async fn describe(path: &[String], endpoint: Option<&str>) -> Result async fn load_spec(url: &str) -> Result<(Value, &'static str)> { if let Ok(spec) = fetch_spec(url).await - && resource_schemas(&spec).is_ok() { - return Ok((spec, "remote")); - } + && resource_schemas(&spec).is_ok() + { + return Ok((spec, "remote")); + } let embedded = serde_json::from_str(EMBEDDED_OPENAPI) .context("embedded Brainpod OpenAPI specification is invalid JSON")?; @@ -75,7 +76,7 @@ async fn load_spec(url: &str) -> Result<(Value, &'static str)> { } async fn fetch_spec(url: &str) -> Result { - let http = reqwest::Client::builder() + let http = crate::http_client_builder()? .timeout(Duration::from_secs(5)) .build() .context("failed to create OpenAPI client")?; diff --git a/src/output.rs b/src/output.rs index e1c3959..cf6c0bd 100644 --- a/src/output.rs +++ b/src/output.rs @@ -7,7 +7,7 @@ use crate::client::{EventStreamMessage, EventWatch}; pub enum CommandOutput { Buffered { value: Value, view: View }, - EventWatch(EventWatch), + EventWatch(Box), } impl CommandOutput { @@ -15,8 +15,8 @@ impl CommandOutput { Self::Buffered { value, view } } - pub const fn event_watch(watch: EventWatch) -> Self { - Self::EventWatch(watch) + pub fn event_watch(watch: EventWatch) -> Self { + Self::EventWatch(Box::new(watch)) } } @@ -42,6 +42,7 @@ pub enum View { ImageBuild, ImageList, ImageInspect, + Tunnel, RevisionList, RevisionGet, RevisionDiff, @@ -60,7 +61,7 @@ pub enum View { pub async fn write(output: CommandOutput, json: bool) -> Result<()> { match output { CommandOutput::Buffered { value, view } => write_buffered(&value, view, json), - CommandOutput::EventWatch(watch) => write_event_watch(watch, json).await, + CommandOutput::EventWatch(watch) => write_event_watch(*watch, json).await, } } @@ -72,6 +73,9 @@ fn write_buffered(value: &Value, view: View, json: bool) -> Result<()> { if json { if matches!(view, View::Login) { write_login_json(&mut stdout, value)?; + } else if matches!(view, View::Tunnel) { + serde_json::to_writer(&mut stdout, value)?; + writeln!(stdout)?; } else { serde_json::to_writer_pretty(&mut stdout, value)?; writeln!(stdout)?; @@ -179,6 +183,7 @@ fn render(value: &Value, view: View, color: bool) -> Vec { View::ImageBuild => render_image_build(value), View::ImageList => render_image_list(value), View::ImageInspect => render_image_inspect(value), + View::Tunnel => vec![format!("Tunnel closed: {}", field(value, "address"))], View::RevisionList => render_revision_list(value), View::RevisionGet => render_revision(value), View::RevisionDiff => render_revision_diff(value), @@ -266,7 +271,11 @@ fn render_describe(value: &Value) -> Vec { if !examples.is_empty() { lines.push(String::new()); lines.push("Examples".to_owned()); - lines.extend(examples.iter().map(|example| format!(" {}", scalar(example)))); + lines.extend( + examples + .iter() + .map(|example| format!(" {}", scalar(example))), + ); } let next_steps = command @@ -292,16 +301,15 @@ fn render_describe(value: &Value) -> Vec { if !guidance.is_empty() { lines.push(String::new()); lines.push("Guidance".to_owned()); - lines.extend( - guidance - .iter() - .map(|item| format!(" - {}", scalar(item))), - ); + lines.extend(guidance.iter().map(|item| format!(" - {}", scalar(item)))); } if let Some(resource_schemas) = value.get("resourceSchemas") { lines.push(String::new()); - lines.extend(render_resource_schema_list(resource_schemas, "Resource kinds")); + lines.extend(render_resource_schema_list( + resource_schemas, + "Resource kinds", + )); } lines @@ -425,9 +433,9 @@ fn render_schema_properties( .and_then(|schema| schema.get("required")) .and_then(Value::as_array); for (name, property) in properties { - let marker = if required.is_some_and(|required| { - required.iter().any(|value| value.as_str() == Some(name)) - }) { + let marker = if required + .is_some_and(|required| required.iter().any(|value| value.as_str() == Some(name))) + { " (required)" } else { "" @@ -449,7 +457,10 @@ fn schema_summary(schema: &Value) -> String { .map(scalar) .map(|value| format!("const {value}")) .or_else(|| { - schema.get("type").and_then(Value::as_str).map(str::to_owned) + schema + .get("type") + .and_then(Value::as_str) + .map(str::to_owned) }) .unwrap_or_else(|| { schema @@ -498,6 +509,10 @@ fn render_config_show(value: &Value) -> Vec { vec![ format!("Config: {}", field(value, "path")), format!("Endpoint: {}", field(value, "endpoint")), + format!( + "Control-plane endpoint: {}", + field(value, "controlPlaneEndpoint") + ), format!("Registry endpoint: {}", field(value, "registryEndpoint")), format!( "API token configured: {}", @@ -653,10 +668,7 @@ fn render_cluster_list(value: &Value) -> Vec { ] }) .collect(); - table( - &["ID", "PROVIDER", "REGION", "ARCHITECTURES"], - rows, - ) + table(&["ID", "PROVIDER", "REGION", "ARCHITECTURES"], rows) } fn render_pod_list(value: &Value) -> Vec { @@ -764,7 +776,10 @@ fn render_blueprint(value: &Value) -> Vec { String::new(), "Documentation".to_owned(), ]; - let body = value.get("body").and_then(Value::as_str).unwrap_or_default(); + let body = value + .get("body") + .and_then(Value::as_str) + .unwrap_or_default(); if body.is_empty() { lines.push(" None".to_owned()); } else { @@ -1383,11 +1398,7 @@ fn truncated_string_list(value: Option<&Value>, limit: usize) -> String { return "-".to_owned(); } - let mut items = values - .iter() - .take(limit) - .map(scalar) - .collect::>(); + let mut items = values.iter().take(limit).map(scalar).collect::>(); if values.len() > limit { items.push("...".to_owned()); } @@ -1444,12 +1455,18 @@ mod tests { assert!(lines.iter().any(|line| line == "Policy (version 1):")); assert!(lines.iter().any(|line| line == " pods-read (allow)")); assert!(lines.iter().any(|line| line == " Actions: pods:read")); - assert!(lines - .iter() - .any(|line| line == " Resources: urn:brain:pod:*")); + assert!( + lines + .iter() + .any(|line| line == " Resources: urn:brain:pod:*") + ); assert!(lines.iter().any(|line| line == "Permissions")); assert!(lines.iter().any(|line| line == " pods:read")); - assert!(lines.iter().any(|line| line == " Excluded resources: none")); + assert!( + lines + .iter() + .any(|line| line == " Excluded resources: none") + ); assert!(lines.iter().any(|line| line == "Links")); assert!(lines.iter().any(|line| line == " Pods: /v1/pods")); } @@ -1566,9 +1583,11 @@ mod tests { assert!(lines.iter().any(|line| line.contains("amd64, arm64, ..."))); assert!(!lines.iter().any(|line| line.contains("VISIBILITY"))); assert!(!lines.iter().any(|line| line.contains("REFERENCE"))); - assert!(!lines - .iter() - .any(|line| line.contains("registry.example/my-pod/api@sha256:abc"))); + assert!( + !lines + .iter() + .any(|line| line.contains("registry.example/my-pod/api@sha256:abc")) + ); assert!(lines.iter().any(|line| line.starts_with("Next: "))); } @@ -1630,16 +1649,26 @@ mod tests { lines[0].split_whitespace().collect::>(), vec!["NAME", "REF", "VALUE", "SECRET"] ); - assert!(lines - .iter() - .any(|line| line.starts_with("host") && line.contains("db") && line.ends_with("no"))); - assert!(lines - .iter() - .any(|line| line.contains("") && line.ends_with("yes"))); - assert!(lines - .iter() - .any(|line| line.contains("") && line.ends_with("no"))); - assert!(!lines.iter().any(|line| line.contains("Generated in-cluster"))); + assert!( + lines.iter().any(|line| line.starts_with("host") + && line.contains("db") + && line.ends_with("no")) + ); + assert!( + lines + .iter() + .any(|line| line.contains("") && line.ends_with("yes")) + ); + assert!( + lines + .iter() + .any(|line| line.contains("") && line.ends_with("no")) + ); + assert!( + !lines + .iter() + .any(|line| line.contains("Generated in-cluster")) + ); } #[test] @@ -1665,9 +1694,11 @@ mod tests { assert!(lines.iter().any(|line| line == "Variables")); assert!(lines.iter().any(|line| line.contains("${.uri}"))); - assert!(lines - .iter() - .any(|line| line.contains("postgres://${.user}@:5432/brainpod"))); + assert!( + lines + .iter() + .any(|line| line.contains("postgres://${.user}@:5432/brainpod")) + ); } #[test] @@ -1686,11 +1717,15 @@ mod tests { })); assert!(lines.iter().any(|line| line.contains("VARIABLES"))); - assert!(lines - .iter() - .any(|line| line.starts_with("Postgres") && line.contains("host, uri"))); - assert!(lines - .iter() - .any(|line| line.starts_with("Disk") && line.contains("none"))); + assert!( + lines + .iter() + .any(|line| line.starts_with("Postgres") && line.contains("host, uri")) + ); + assert!( + lines + .iter() + .any(|line| line.starts_with("Disk") && line.contains("none")) + ); } } diff --git a/src/tunnel.rs b/src/tunnel.rs new file mode 100644 index 0000000..b4ded11 --- /dev/null +++ b/src/tunnel.rs @@ -0,0 +1,691 @@ +use std::fmt; +use std::io::{self, IsTerminal as _, Write as _}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use serde_json::json; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpStream, tcp}; +use tokio::sync::mpsc; +use tokio::task::JoinSet; +use tokio_stream::wrappers::ReceiverStream; +use tonic::metadata::MetadataValue; +use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; +use tonic::{Request, Streaming}; +use uuid::Uuid; + +use crate::client::Client; +use crate::cmd::TunnelArgs; +use crate::output::{CommandOutput, View}; + +mod pb { + tonic::include_proto!("brainpod.tunnel.v1"); +} + +use pb::tunnel_broker_client::TunnelBrokerClient; +use pb::tunnel_service_client::TunnelServiceClient; +use pb::{Chunk, CloseSessionRequest, DatabaseEngine, OpenSessionRequest}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_CHUNK_BYTES: usize = 64 * 1024; +const INCLUDE_CREDENTIALS_METADATA: &str = "brainpod-include-credentials"; +const DATABASE_PASSWORD_METADATA: &str = "brainpod-database-password-bin"; + +#[derive(Debug)] +struct RemoteTunnelError { + operation: &'static str, + code: Option, + message: String, + ends_tunnel: bool, +} + +impl RemoteTunnelError { + fn status(operation: &'static str, status: tonic::Status) -> Self { + let code = status.code(); + Self { + operation, + code: Some(code), + message: status.message().to_owned(), + ends_tunnel: matches!( + code, + tonic::Code::Unauthenticated + | tonic::Code::PermissionDenied + | tonic::Code::NotFound + | tonic::Code::DeadlineExceeded + | tonic::Code::InvalidArgument + | tonic::Code::FailedPrecondition + ), + } + } + + fn message(operation: &'static str, message: impl Into) -> Self { + Self { + operation, + code: None, + message: message.into(), + ends_tunnel: false, + } + } + + fn credentials(message: impl Into) -> Self { + Self { + operation: "tunnel credentials unavailable", + code: None, + message: message.into(), + ends_tunnel: true, + } + } +} + +impl fmt::Display for RemoteTunnelError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.operation, self.message)?; + if let Some(code) = self.code { + write!(formatter, " ({code:?})")?; + } + Ok(()) + } +} + +impl std::error::Error for RemoteTunnelError {} + +pub async fn handle( + client: &Client, + pod: &str, + args: TunnelArgs, + control_plane_endpoint: &str, + api_token: &str, + json_output: bool, +) -> Result { + let database_id = client + .resolve_resource(pod, &args.resource) + .await? + .uuid + .parse::() + .context("Brainpod API returned an invalid resource UUID")?; + + let channel = connect_channel(control_plane_endpoint) + .await + .context("failed to connect to the Brainpod tunnel broker")?; + let mut broker = TunnelBrokerClient::new(channel); + let mut open_request = Request::new(OpenSessionRequest { + database_id: database_id.to_string(), + request_id: Uuid::new_v4().to_string(), + }); + open_request + .metadata_mut() + .insert("authorization", bearer_value(api_token)?); + let session = broker + .open_session(open_request) + .await + .map_err(|status| RemoteTunnelError::status("failed to create tunnel session", status))? + .into_inner(); + let engine = DatabaseEngine::try_from(session.engine) + .ok() + .filter(|engine| *engine != DatabaseEngine::Unspecified) + .ok_or_else(|| anyhow!("Brainpod tunnel broker returned an unknown database engine"))?; + let listen_address = args + .listen_address + .unwrap_or_else(|| SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), default_port(engine))); + + let proxy_result = run_proxy( + listen_address, + &session.endpoint, + &session.ticket, + engine, + json_output, + args.skip_preflight, + ) + .await; + + let mut close_request = Request::new(CloseSessionRequest { + session_id: session.session_id, + }); + close_request + .metadata_mut() + .insert("authorization", bearer_value(api_token)?); + let close_result = broker + .close_session(close_request) + .await + .map_err(|status| RemoteTunnelError::status("failed to close tunnel session", status)); + + let bound_address = match (proxy_result, close_result) { + (Err(error), _) => return Err(error), + (Ok(_), Err(error)) => return Err(error.into()), + (Ok(bound_address), Ok(_)) => bound_address, + }; + + Ok(CommandOutput::new( + json!({ + "event": "closed", + "address": bound_address, + "engine": engine_name(engine), + }), + View::Tunnel, + )) +} + +async fn run_proxy( + listen_address: SocketAddr, + endpoint: &str, + ticket: &str, + engine: DatabaseEngine, + json_output: bool, + skip_preflight: bool, +) -> Result { + let listener = tokio::net::TcpListener::bind(listen_address) + .await + .with_context(|| format!("failed to bind tunnel listener at {listen_address}"))?; + let listen_address = listener + .local_addr() + .context("failed to determine tunnel listener address")?; + let channel = connect_channel(endpoint) + .await + .context("failed to connect to the Brainpod tunnel service")?; + let client = TunnelServiceClient::new(channel); + let ticket = ticket.to_owned(); + let password = if skip_preflight { + None + } else { + let mut connection = open_remote(client.clone(), ticket.clone(), true).await?; + let password = connection + .password + .take() + .ok_or_else(|| RemoteTunnelError::credentials("remote service omitted the password"))?; + drop(connection); + Some(password) + }; + announce(listen_address, engine, password.as_deref(), json_output)?; + let mut connections = JoinSet::new(); + let shutdown = tokio::signal::ctrl_c(); + tokio::pin!(shutdown); + + loop { + tokio::select! { + result = &mut shutdown => { + result.context("failed to listen for Ctrl-C")?; + break; + } + accepted = listener.accept() => { + let (stream, peer) = accepted.context("failed to accept tunnel connection")?; + write_connection_event(peer, ConnectionEvent::Opened, json_output)?; + let client = client.clone(); + let ticket = ticket.clone(); + connections.spawn(async move { + (peer, relay(stream, client, ticket).await) + }); + } + result = connections.join_next(), if !connections.is_empty() => { + if let Some(result) = result { + let (peer, result) = result.context("tunnel connection task failed")?; + match result { + Ok(()) => write_connection_event(peer, ConnectionEvent::Closed, json_output)?, + Err(error) => { + let message = connection_error_message(&error); + write_connection_event( + peer, + ConnectionEvent::Failed(&message), + json_output, + )?; + if error + .downcast_ref::() + .is_some_and(|error| error.ends_tunnel) + { + return Err(anyhow!("tunnel closed: {message}")); + } + } + } + } + } + } + } + + connections.abort_all(); + while connections.join_next().await.is_some() {} + Ok(listen_address) +} + +struct RemoteConnection { + sender: mpsc::Sender, + inbound: Streaming, + password: Option, +} + +async fn relay( + stream: TcpStream, + client: TunnelServiceClient, + ticket: String, +) -> Result<()> { + let remote = open_remote(client, ticket, false).await?; + relay_open(stream, remote).await +} + +async fn open_remote( + mut client: TunnelServiceClient, + ticket: String, + include_credentials: bool, +) -> Result { + let (sender, receiver) = mpsc::channel(16); + let mut request = Request::new(ReceiverStream::new(receiver)); + request + .metadata_mut() + .insert("authorization", bearer_value(&ticket)?); + if include_credentials { + request + .metadata_mut() + .insert(INCLUDE_CREDENTIALS_METADATA, "true".parse()?); + } + let response = client.open(request).await.map_err(|status| { + RemoteTunnelError::status("tunnel service rejected the connection", status) + })?; + let password = if include_credentials { + let value = response + .metadata() + .get_bin(DATABASE_PASSWORD_METADATA) + .ok_or_else(|| RemoteTunnelError::credentials("remote service omitted the password"))?; + let bytes = value.to_bytes().map_err(|_| { + RemoteTunnelError::credentials("remote service returned an invalid password") + })?; + Some(String::from_utf8(bytes.to_vec()).map_err(|_| { + RemoteTunnelError::credentials("remote service returned an invalid password") + })?) + } else { + None + }; + Ok(RemoteConnection { + sender, + inbound: response.into_inner(), + password, + }) +} + +async fn relay_open(stream: TcpStream, remote: RemoteConnection) -> Result<()> { + let (reader, writer) = stream.into_split(); + tokio::try_join!( + upload(reader, remote.sender), + download(writer, remote.inbound) + )?; + Ok(()) +} + +async fn upload(mut reader: tcp::OwnedReadHalf, sender: mpsc::Sender) -> Result<()> { + let mut buffer = vec![0; MAX_CHUNK_BYTES]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + return Ok(()); + } + sender + .send(Chunk { + data: buffer[..read].to_vec(), + }) + .await + .map_err(|_| { + RemoteTunnelError::message("tunnel upload failed", "remote stream closed") + })?; + } +} + +async fn download(mut writer: tcp::OwnedWriteHalf, mut inbound: Streaming) -> Result<()> { + while let Some(chunk) = inbound + .message() + .await + .map_err(|status| RemoteTunnelError::status("tunnel download failed", status))? + { + if chunk.data.is_empty() { + return Err(RemoteTunnelError::message( + "tunnel download failed", + "remote service returned an empty chunk", + ) + .into()); + } + writer.write_all(&chunk.data).await?; + } + writer.shutdown().await?; + Ok(()) +} + +async fn connect_channel(endpoint: &str) -> Result { + crate::install_crypto_provider()?; + let endpoint_builder = Endpoint::from_shared(endpoint.to_owned())? + .connect_timeout(CONNECT_TIMEOUT) + .tcp_keepalive(Some(Duration::from_secs(30))) + .http2_keep_alive_interval(Duration::from_secs(30)) + .keep_alive_timeout(Duration::from_secs(20)) + .keep_alive_while_idle(true); + let endpoint_builder = if endpoint.starts_with("https://") { + endpoint_builder.tls_config(ClientTlsConfig::new().with_enabled_roots())? + } else { + endpoint_builder + }; + Ok(endpoint_builder.connect().await?) +} + +fn announce( + address: SocketAddr, + engine: DatabaseEngine, + password: Option<&str>, + json_output: bool, +) -> Result<()> { + let details = connection_details(address, engine, password); + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + if json_output { + writeln!( + stdout, + "{}", + serde_json::to_string(&json!({ + "event": "listening", + "address": address, + "host": address.ip(), + "localPort": address.port(), + "remotePort": details.remote_port, + "engine": engine_name(engine), + "clientCommand": details.client_command, + }))? + ) + .context("failed to write tunnel address")?; + if let Some(password) = password { + writeln!( + stdout, + "{}", + serde_json::to_string(&json!({ + "event": "credentials", + "username": details.username, + "database": details.database, + "password": password, + "dsn": details.dsn, + }))? + ) + .context("failed to write database credentials")?; + } + } else { + let color = stdout.is_terminal(); + let title = style("◆ Brainpod tunnel", "1;35", color); + let engine = style(details.display_name, "1;36", color); + let ready = style("● Ready", "1;32", color); + let label = |value: &str| style(&format!("{value:<10}"), "2", color); + + writeln!(stdout, "╭─ {title}")?; + writeln!(stdout, "│")?; + writeln!(stdout, "│ {engine}")?; + writeln!(stdout, "│ {} {address}", label("Local"))?; + writeln!( + stdout, + "│ {} {}:{}", + label("Remote"), + details.display_name, + details.remote_port + )?; + if details.username.is_some() || details.database.is_some() || password.is_some() { + writeln!(stdout, "│")?; + } + if let Some(username) = details.username { + writeln!(stdout, "│ {} {username}", label("Username"))?; + } + if let Some(database) = details.database { + writeln!(stdout, "│ {} {database}", label("Database"))?; + } + if let Some(password) = password { + writeln!(stdout, "│ {} {password}", label("Password"))?; + } + writeln!(stdout, "│")?; + writeln!(stdout, "├─ {}", style("Client", "1", color))?; + writeln!(stdout, "│ {}", details.client_command)?; + if let Some(dsn) = details.dsn { + writeln!(stdout, "│")?; + writeln!(stdout, "├─ {}", style("DSN", "1", color))?; + writeln!(stdout, "│ {dsn}")?; + } + writeln!(stdout, "│")?; + writeln!(stdout, "╰─ {ready} · press Ctrl+C to stop")?; + } + stdout.flush().context("failed to flush tunnel banner") +} + +enum ConnectionEvent<'a> { + Opened, + Closed, + Failed(&'a str), +} + +fn write_connection_event( + peer: SocketAddr, + event: ConnectionEvent<'_>, + json_output: bool, +) -> Result<()> { + if json_output { + let (event, error) = match event { + ConnectionEvent::Opened => ("connectionOpened", None), + ConnectionEvent::Closed => ("connectionClosed", None), + ConnectionEvent::Failed(error) => ("connectionFailed", Some(error)), + }; + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + writeln!( + stdout, + "{}", + serde_json::to_string(&json!({ + "event": event, + "peer": peer, + "error": error, + }))? + )?; + stdout.flush()?; + return Ok(()); + } + + let stderr = io::stderr(); + let color = stderr.is_terminal(); + let mut stderr = stderr.lock(); + match event { + ConnectionEvent::Opened => { + writeln!(stderr, "{} {peer} connected", style("→", "1;36", color))? + } + ConnectionEvent::Closed => { + writeln!(stderr, "{} {peer} closed", style("✓", "1;32", color))? + } + ConnectionEvent::Failed(error) => { + writeln!(stderr, "{} {peer} {error}", style("×", "1;31", color))? + } + } + stderr.flush()?; + Ok(()) +} + +fn connection_error_message(error: &anyhow::Error) -> String { + error.downcast_ref::().map_or_else( + || error.root_cause().to_string(), + |error| error.message.clone(), + ) +} + +fn style(value: &str, code: &str, enabled: bool) -> String { + if enabled { + format!("\u{1b}[{code}m{value}\u{1b}[0m") + } else { + value.to_owned() + } +} + +struct ConnectionDetails { + display_name: &'static str, + remote_port: u16, + username: Option<&'static str>, + database: Option<&'static str>, + client_command: String, + dsn: Option, +} + +fn connection_details( + address: SocketAddr, + engine: DatabaseEngine, + password: Option<&str>, +) -> ConnectionDetails { + let host = address.ip(); + let dsn_host = dsn_host(host); + let port = address.port(); + let password = password.map(percent_encode); + match engine { + DatabaseEngine::Postgres => ConnectionDetails { + display_name: "PostgreSQL", + remote_port: 5432, + username: Some("brainpod"), + database: Some("brainpod"), + client_command: format!( + "psql \"host={host} port={port} user=brainpod dbname=brainpod sslmode=require\"" + ), + dsn: password.map(|password| { + format!( + "postgres://brainpod:{password}@{dsn_host}:{port}/brainpod?sslmode=require" + ) + }), + }, + DatabaseEngine::Mariadb => ConnectionDetails { + display_name: "MariaDB", + remote_port: 3306, + username: Some("brainpod"), + database: Some("brainpod"), + client_command: format!( + "mariadb --ssl --host {host} --port {port} --user brainpod --password brainpod" + ), + dsn: password.map(|password| { + format!("mysql://brainpod:{password}@{dsn_host}:{port}/brainpod") + }), + }, + DatabaseEngine::Valkey => ConnectionDetails { + display_name: "Valkey", + remote_port: 6379, + username: None, + database: None, + client_command: format!("valkey-cli --tls --insecure -h {host} -p {port}"), + dsn: password + .map(|password| format!("rediss://:{password}@{dsn_host}:{port}")), + }, + DatabaseEngine::Mssql => ConnectionDetails { + display_name: "Microsoft SQL Server", + remote_port: 1433, + username: Some("brainpod"), + database: Some("brainpod"), + client_command: format!( + "sqlcmd -S tcp:{host},{port} -U brainpod -d brainpod -C" + ), + dsn: password.map(|password| { + format!( + "sqlserver://brainpod:{password}@{dsn_host}:{port}?database=brainpod&encrypt=true&trustServerCertificate=true" + ) + }), + }, + DatabaseEngine::Unspecified => ConnectionDetails { + display_name: "Database", + remote_port: 0, + username: None, + database: None, + client_command: address.to_string(), + dsn: None, + }, + } +} + +fn dsn_host(host: IpAddr) -> String { + match host { + IpAddr::V4(host) => host.to_string(), + IpAddr::V6(host) => format!("[{host}]"), + } +} + +fn percent_encode(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn bearer_value(token: &str) -> Result> { + format!("Bearer {token}") + .parse() + .context("API token contains invalid header characters") +} + +const fn default_port(engine: DatabaseEngine) -> u16 { + match engine { + DatabaseEngine::Postgres => 5432, + DatabaseEngine::Mariadb => 3306, + DatabaseEngine::Valkey => 6379, + DatabaseEngine::Mssql => 1433, + DatabaseEngine::Unspecified => 0, + } +} + +const fn engine_name(engine: DatabaseEngine) -> &'static str { + match engine { + DatabaseEngine::Postgres => "postgres", + DatabaseEngine::Mariadb => "mariadb", + DatabaseEngine::Valkey => "valkey", + DatabaseEngine::Mssql => "mssql", + DatabaseEngine::Unspecified => "unspecified", + } +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use super::{DatabaseEngine, RemoteTunnelError, connection_details}; + + #[test] + fn formats_remote_status_without_metadata() { + let error = RemoteTunnelError::status( + "tunnel service rejected the connection", + tonic::Status::unavailable("database unavailable"), + ); + + assert_eq!( + error.to_string(), + "tunnel service rejected the connection: database unavailable (Unavailable)" + ); + assert!(!error.ends_tunnel); + } + + #[test] + fn ends_expired_or_invalid_sessions() { + for status in [ + tonic::Status::unauthenticated("invalid tunnel credentials"), + tonic::Status::deadline_exceeded("tunnel session expired"), + tonic::Status::failed_precondition("database credentials unavailable"), + ] { + assert!(RemoteTunnelError::status("connection failed", status).ends_tunnel); + } + } + + #[test] + fn builds_postgres_banner_details() { + let address = "127.0.0.1:15432".parse::().unwrap(); + let details = connection_details(address, DatabaseEngine::Postgres, Some("p@ ss")); + + assert_eq!(details.display_name, "PostgreSQL"); + assert_eq!(details.remote_port, 5432); + assert_eq!(details.username, Some("brainpod")); + assert_eq!(details.database, Some("brainpod")); + assert_eq!( + details.dsn.as_deref(), + Some("postgres://brainpod:p%40%20ss@127.0.0.1:15432/brainpod?sslmode=require") + ); + assert!(details.client_command.starts_with("psql ")); + } + + #[test] + fn omits_dsn_without_preflight_credentials() { + let address = "127.0.0.1:16379".parse::().unwrap(); + let details = connection_details(address, DatabaseEngine::Valkey, None); + + assert_eq!(details.remote_port, 6379); + assert!(details.dsn.is_none()); + } +}