commit c44b515ce0d17db02e235f6eb5e8ee10f1f1773a Author: Chris Beck Date: Thu Dec 4 16:34:31 2025 -0700 initial commit diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..cb9efc7 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold", "-C", "target-cpu=skylake", '--cfg=curve25519_dalek_backend="simd"', '--cfg=feature="precomputed-tables"'] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b60de5b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c065541 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2896 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +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 = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "circular-buffer" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c638459986b83c2b885179bd4ea6a2cbb05697b001501a56adb3a3d230803b" + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "conf" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4201762fcfd72600b1c852bf6a731a1797ce6b56d1d6f7a32a206a1078ae0438" +dependencies = [ + "anstyle", + "clap", + "clap_lex", + "conf_derive", + "serde", +] + +[[package]] +name = "conf-extra" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3f2bf02dc4f104be1686c755e4f7401e145cbdbccba503741df5e6540e08ed" +dependencies = [ + "chrono", + "go-parse-duration", + "serde", + "zeroize", +] + +[[package]] +name = "conf_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35060dcea66f85eb49bf7834d451089a6d5cde0f75957aa78bcc7029dc050015" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.10.0", + "byteorder", + "core-foundation", + "core-graphics", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "go-parse-duration" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558b88954871f5e5b2af0e62e2e176c8bde7a6c2c4ed41b13d138d96da2e2cbd" + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[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", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpsee" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" +dependencies = [ + "jsonrpsee-core", + "jsonrpsee-proc-macros", + "jsonrpsee-types", + "tracing", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "316c96719901f05d1137f19ba598b5fe9c9bc39f4335f67f6be8613921946480" +dependencies = [ + "async-trait", + "futures-timer", + "futures-util", + "http", + "jsonrpsee-types", + "pin-project", + "rustc-hash", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "jsonrpsee-proc-macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc88ff4688e43cc3fa9883a8a95c6fa27aa2e76c96e610b737b6554d650d7fd5" +dependencies = [ + "http", + "serde", + "serde_json", + "thiserror", +] + +[[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.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "chrono", + "font-kit", + "lazy_static", + "num-traits", + "pathfinder_geometry", + "plotters-backend", + "plotters-bitmap", + "ttf-parser", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-bitmap" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" +dependencies = [ + "image", + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prom-client" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "conf", + "conf-extra", + "displaydoc", + "plotters", + "reqwest", + "rust_decimal", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-gateway" +version = "0.1.0" +dependencies = [ + "chrono", + "circular-buffer", + "conf", + "conf-extra", + "displaydoc", + "dotenvy", + "futures-util", + "http-body-util", + "humantime", + "hyper", + "hyper-util", + "jsonrpsee", + "prom-client", + "rand 0.9.2", + "reqwest", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "syslog_rfc5424", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "url", + "walkdir", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "syslog_rfc5424" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0325229868961bd202714fc201a6e8b3cd7cb93ecdd7abd5b6556fae533e60cd" +dependencies = [ + "thiserror", + "time", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "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.111", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.111", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "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 = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "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.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.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.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.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.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.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.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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4e08b2b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,53 @@ +[workspace] +resolver = "2" +members = [ + "prom-client", + "signal-gateway", +] + +[workspace.package] +edition = "2024" + +[profile.release] +# line-tables-only: keeps file/line info for backtraces, but strips variable/type debug info +# This significantly reduces binary size while preserving useful stack traces +debug = "line-tables-only" +# LTO eliminates dead code across crates, reducing binary size esp. for the AWS crates +# "thin" is faster than "fat" LTO while still providing most benefits +lto = "thin" + +[workspace.lints.clippy] +assigning_clones = "allow" +result_large_err = "allow" + +[workspace.dependencies] +prom-client = { path = "prom-client" } + +async-trait = "0.1" +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } +circular-buffer = "1.2" +conf = "0.4" +conf-extra = "0.1" +displaydoc = "0.2" +dotenvy = "0.15" +futures-util = "0.3" +http-body-util = "0.1" +humantime = "2" +hyper = { version = "1.7", features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["tokio", "server", "server-auto"] } +jsonrpsee = { version = "0.26", features = ["macros", "async-client"] } +plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "ttf", "datetime", "area_series", "line_series", "full_palette"] } +rand = "0.9" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rust_decimal = { version = "1", features = ["serde-with-str"] } +rustls = "0.23" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +syslog_rfc5424 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio-util = { version = "0.7", features = ["codec"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" +walkdir = "2" diff --git a/prom-client/Cargo.toml b/prom-client/Cargo.toml new file mode 100644 index 0000000..c8d05ba --- /dev/null +++ b/prom-client/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "prom-client" +version = "0.1.0" +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +displaydoc = { workspace = true } +reqwest = { workspace = true } +rust_decimal = { workspace = true } +serde = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +plotters = { workspace = true, optional = true } + +[dev-dependencies] +conf = { workspace = true } +conf-extra = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing-subscriber = { workspace = true } + +[features] +default = ["plot"] +plot = ["dep:plotters"] diff --git a/prom-client/src/builders.rs b/prom-client/src/builders.rs new file mode 100644 index 0000000..1dd1d84 --- /dev/null +++ b/prom-client/src/builders.rs @@ -0,0 +1,70 @@ +use super::QueryRangeRequest; +use chrono::{DateTime, TimeDelta, Utc}; +use std::{ops::Range, time::Duration}; + +pub struct QueryRangeRequestBuilder { + query: String, + range: Option>>, + step: Option, + count: usize, +} + +impl QueryRangeRequestBuilder { + pub fn new(query: String) -> Self { + Self { + query, + range: None, + step: None, + count: 256, + } + } + + pub fn range(mut self, range: Range>) -> Self { + if self.range.is_some() { + panic!("already set range: {:?}", self.range); + } + self.range = Some(range); + self + } + + pub fn since(mut self, time: Duration) -> Self { + if self.range.is_some() { + panic!("already set range: {:?}", self.range); + } + let end = Utc::now(); + let start = end - TimeDelta::from_std(time).unwrap(); + + self.range = Some(start..end); + self + } + + pub fn step(mut self, step: Duration) -> Self { + if self.step.is_some() { + panic!("already set step: {:?}", self.step); + } + self.step = Some(step); + self + } + + pub fn count(mut self, count: usize) -> Self { + self.count = count; + self + } + + pub fn build(self) -> QueryRangeRequest { + let query = self.query; + let range = self.range.unwrap(); + + let step = self.step.unwrap_or_else(|| { + let delta = range.end - range.start; + (delta / (self.count as i32)).to_std().unwrap() + }); + + QueryRangeRequest { + query, + start: range.start, + end: range.end, + step: step.as_secs_f64(), + } + } +} diff --git a/prom-client/src/error.rs b/prom-client/src/error.rs new file mode 100644 index 0000000..980c7d5 --- /dev/null +++ b/prom-client/src/error.rs @@ -0,0 +1,30 @@ +use displaydoc::Display; +use url::ParseError; + +#[derive(Debug, Display)] +pub enum Error { + /// URL: {0} + Url(ParseError), + /// Reqwest: {0} + Reqwest(reqwest::Error), + /// API: {0}: {1} + API(String, String), + /// Unexpected Result Type: {0} + UnexpectedResultType(String), + /// Missing data on success response + MissingData, +} + +impl From for Error { + fn from(src: reqwest::Error) -> Self { + Self::Reqwest(src) + } +} + +impl From for Error { + fn from(src: ParseError) -> Self { + Self::Url(src) + } +} + +impl std::error::Error for Error {} diff --git a/prom-client/src/labels.rs b/prom-client/src/labels.rs new file mode 100644 index 0000000..17530d8 --- /dev/null +++ b/prom-client/src/labels.rs @@ -0,0 +1,78 @@ +//! Helpers for extracting common labels from metrics before presenting them +use std::{ + collections::BTreeMap, + fmt::{Debug, Display}, +}; + +/// A set of labels extracted by ExtractLabels +pub type Labels = BTreeMap; + +/// Common labels extracted from a very generic sequence of metric labels (key value pairs) +pub struct ExtractLabels { + pub name: String, + pub common_labels: Labels, + pub specific_labels: Vec, +} + +impl ExtractLabels { + pub fn new<'a, I, KV, K, V>(src: I, skip_labels: &[String]) -> Self + where + I: Iterator, + KV: Clone + Debug + 'a, + K: Display + 'a, + V: Display + 'a, + &'a KV: IntoIterator, + { + // Common labels needs to be String -> Option, because when we find a conflict, + // we need to poison that key (by putting None) + let mut common_labels = BTreeMap::>::default(); + let mut specific_labels: Vec = src + .map(|kv| { + let labels: Labels = kv + .into_iter() + .map(|(k, v)| { + let k = k.to_string(); + let v = v.to_string(); + if let Some(existing_val) = common_labels.get_mut(&k) { + // If the existing value is None, we already eliminated this label, + // so don't add it back. + if let Some(common_val) = existing_val + && common_val != &v + { + *existing_val = None; + } + } else { + common_labels.insert(k.clone(), Some(v.clone())); + } + (k, v) + }) + .collect(); + labels + }) + .collect(); + + let mut common_labels = common_labels + .into_iter() + .filter_map(|(k, maybe_v)| maybe_v.map(|v| (k, v))) + .collect::(); + + for sl in skip_labels { + common_labels.remove(sl); + } + for sp in &mut specific_labels { + for sl in skip_labels { + sp.remove(sl); + } + for cl in common_labels.keys() { + sp.remove(cl); + } + } + let name = common_labels.remove("__name__").unwrap_or_default(); + + ExtractLabels { + name, + common_labels, + specific_labels, + } + } +} diff --git a/prom-client/src/lib.rs b/prom-client/src/lib.rs new file mode 100644 index 0000000..08d6a8e --- /dev/null +++ b/prom-client/src/lib.rs @@ -0,0 +1,140 @@ +//! API for getting time series data from prometheus +//! +//! To use it, instantiate one of the request objects, +//! e.g. QueryRequest or QueryRangeRequest. When it's helpful a builder is provided. +//! +//! Then use `PromRequest` trait and call `send` or `send_with_client`. +//! This takes the prometheus url, and optionally a reqwest client to use. +//! +//! On success, the result is `PromData`. One would usually call `into_matrix()?` +//! or `into_vector()?` as expected for the request that is made. +//! +//! In prometheus, metric labels are just a set of key-value pairs. However, +//! if you are expecting certain structure, you may use any KV object that implements +//! `serde::Deserialize` in the `PromData` that results from the call. + +use chrono::{DateTime, Utc}; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt::Debug; + +mod builders; +pub use builders::QueryRangeRequestBuilder; + +mod error; +pub use error::Error; + +mod labels; +pub use labels::{ExtractLabels, Labels}; + +mod messages; +use messages::PromResponse; +pub use messages::{ + AlertInfo, AlertStatus, AlertsResponse, MetricTimeseries, MetricValue, PromData, +}; + +#[cfg(feature = "plot")] +pub mod plot; + +mod traits; +pub use traits::PromRequest; + +/// Query parameters for /api/v1/query prometheus request +#[derive(Clone, Debug, Serialize)] +pub struct QueryRequest { + pub query: String, + pub time: Option>, +} + +impl PromRequest for QueryRequest { + const PATH: &str = "/api/v1/query"; + type Output = PromData; +} + +/// Query parameters for /api/v1/query_range prometheus request +/// Use builder to populate it +#[derive(Clone, Debug, Serialize)] +pub struct QueryRangeRequest { + query: String, + start: DateTime, + end: DateTime, + step: f64, +} + +impl QueryRangeRequest { + /// Get builder for query range request with given query + pub fn builder(query: impl Into) -> QueryRangeRequestBuilder { + QueryRangeRequestBuilder::new(query.into()) + } +} + +impl PromRequest for QueryRangeRequest { + const PATH: &str = "/api/v1/query_range"; + type Output = PromData; +} + +/// Query parameters for /api/v1/series prometheus request +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct SeriesRequest { + pub matches: MatchList, +} + +impl PromRequest for SeriesRequest { + const PATH: &str = "/api/v1/series"; + type Output = Vec; +} + +/// Query parameters for /api/v1/labels prometheus request +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct LabelsRequest { + pub matches: MatchList, +} + +impl PromRequest for LabelsRequest { + const PATH: &str = "/api/v1/labels"; + type Output = Vec; +} + +/// Query parameters for /api/v1/alerts prometheus request +#[derive(Clone, Debug, Serialize)] +pub struct AlertsRequest {} + +impl PromRequest for AlertsRequest { + const PATH: &str = "/api/v1/alerts"; + type Output = AlertsResponse; +} + +/// Represents a sequence of match[]=...,match[]=... +/// query parameters required by parts of prometheus api +#[derive(Clone, Debug)] +pub struct MatchList(Vec); + +impl Serialize for MatchList { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for v in &self.0 { + map.serialize_entry("match[]", v)?; + } + map.end() + } +} + +impl From> for MatchList { + fn from(src: Vec) -> Self { + Self(src) + } +} + +impl FromIterator for MatchList { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + Self(iter.into_iter().collect()) + } +} diff --git a/prom-client/src/messages.rs b/prom-client/src/messages.rs new file mode 100644 index 0000000..89eda2d --- /dev/null +++ b/prom-client/src/messages.rs @@ -0,0 +1,159 @@ +// The prometheus http interface (at port 9090) renders graphs in JS and gets the raw data using API requests like this: +// +// GET http://localhost:9090/api/v1/query_range?query=tick_time{quantile="0.99"}&step=14&start=1762534433.802&end=1762538033.802 +// +// Response is: +// +// { +// status: "success" +// data: { +// resultType: "matrix", +// result: [ +// { +// metric: { __name__: "tick_time", instance: "x.y.z.w", job: "ec2", .. }, +// values: [ +// [ 1762534433.802, "1.8974293514080933" ], +// [ 1762534447.802, "2.029724353457351" ], +// .. +// ] +// } +// ] +// } +// } +// +// For more detail see: +// https://prometheus.io/docs/prometheus/latest/querying/api/ + +use crate::Error; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, de::DeserializeOwned}; +use std::{collections::HashMap, fmt::Debug}; +use tracing::warn; + +#[derive(Clone, Debug, Deserialize)] +#[serde(bound = "KV: DeserializeOwned")] +pub struct MetricValue> +where + KV: Clone + Debug, +{ + pub metric: KV, + #[serde(default)] + pub value: Option<(f64, Decimal)>, + // TODO: Include histograms +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(bound = "KV: DeserializeOwned")] +pub struct MetricTimeseries> +where + KV: Clone + Debug, +{ + pub metric: KV, + #[serde(default)] + pub values: Vec<(f64, Decimal)>, + // TODO: Include histograms +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(bound = "KV: DeserializeOwned")] +#[serde(tag = "resultType", content = "result", rename_all = "camelCase")] +pub enum PromData> +where + KV: Clone + Debug, +{ + Matrix(Vec>), + Vector(Vec>), +} + +impl PromData +where + KV: Clone + Debug, +{ + pub fn into_matrix(self) -> Result>, Error> { + match self { + Self::Matrix(data) => Ok(data), + _ => Err(Error::UnexpectedResultType(format!("{self:?}"))), + } + } + + pub fn into_vector(self) -> Result>, Error> { + match self { + Self::Vector(data) => Ok(data), + _ => Err(Error::UnexpectedResultType(format!("{self:?}"))), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum Status { + Success, + Error, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(bound = "T: DeserializeOwned", rename_all = "camelCase")] +pub struct PromResponse +where + T: Clone + Debug, +{ + pub status: Status, + #[serde(default)] + pub data: Option, + #[serde(default)] + pub error_type: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub warnings: Vec, +} + +impl PromResponse +where + T: Clone + Debug, +{ + pub fn into_result(self) -> Result { + for warning in self.warnings { + warn!("Prometheus API response: {warning}"); + } + + match self.status { + Status::Success => Ok(self.data.ok_or(Error::MissingData)?), + Status::Error => Err(Error::API( + self.error_type.unwrap_or_default(), + self.error.unwrap_or_default(), + )), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(bound = "KV: DeserializeOwned")] +pub struct AlertsResponse> +where + KV: Clone + Debug, +{ + pub alerts: Vec>, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(bound = "KV: DeserializeOwned", rename_all = "camelCase")] +pub struct AlertInfo> +where + KV: Clone + Debug, +{ + pub active_at: DateTime, + pub annotations: KV, + pub labels: KV, + pub state: AlertStatus, + pub value: String, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AlertStatus { + Pending, + Firing, + Resolved, +} diff --git a/prom-client/src/plot.rs b/prom-client/src/plot.rs new file mode 100644 index 0000000..ef11832 --- /dev/null +++ b/prom-client/src/plot.rs @@ -0,0 +1,276 @@ +use crate::{ExtractLabels, MetricTimeseries}; +use chrono::{DateTime, FixedOffset, TimeZone, Utc}; +use plotters::prelude::*; +use rust_decimal::prelude::ToPrimitive; +use std::{ + borrow::Borrow, + error::Error, + fmt::{Debug, Display, Write}, + ops::Range, + path::Path, +}; + +pub struct PlotStyle { + pub drawing_area: (u32, u32), + pub background: RGBAColor, + pub grid: RGBAColor, + pub axis: RGBAColor, + pub text_color: RGBAColor, + pub text_font: String, + pub text_size: u32, + pub caption_size: u32, + pub data_colors: Vec, + pub threshold_color: RGBAColor, + pub skip_labels: Vec, + pub utc_offset_hours: i32, + /// Optional title override - used when prometheus aggregations remove __name__ + pub title: Option, +} + +impl Default for PlotStyle { + fn default() -> Self { + Self { + drawing_area: (1920, 1200), + background: WHITE.into(), + grid: RGBAColor(100, 100, 100, 0.5), + axis: BLACK.into(), + text_color: BLACK.into(), + text_font: "sans-serif".into(), + text_size: 18, + caption_size: 36, + data_colors: [ + GREEN, + BLUE, + full_palette::ORANGE, + YELLOW, + MAGENTA, + full_palette::TEAL, + full_palette::PURPLE, + ] + .iter() + .cloned() + .map(Into::into) + .collect(), + threshold_color: RED.mix(0.2), + skip_labels: vec!["job".into(), "instance".into()], + utc_offset_hours: -7, + title: None, + } + } +} + +impl PlotStyle { + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } +} + +pub enum PlotThreshold { + GreaterThan(f64), + LessThan(f64), +} + +impl PlotStyle { + pub fn dark_mode(mut self) -> Self { + self.background = BLACK.into(); + self.grid = RGBAColor(100, 100, 100, 0.5); + self.axis = WHITE.into(); + self.text_color = WHITE.into(); + self + } + + /// Plot a collection of metric timeseries data from prometheus, and possibly a "threshold" defined in an alert. + /// Write the result to a path. The file extension of the path will determine the format, e.g. png, gif, etc. + pub fn plot_timeseries( + &self, + path: impl AsRef, + mts: &[MetricTimeseries], + plot_threshold: Option, + ) -> Result<(), Box> + where + KV: Clone + Debug, + K: Display, + V: Display, + for<'a> &'a KV: IntoIterator, + { + // Prepare to plot by scanning the data, finding x and y bounds, common labels, etc. + let ExtractLabels { + name, + common_labels, + specific_labels, + } = ExtractLabels::new(mts.iter().map(|mts| &mts.metric), &self.skip_labels); + let PreparedPlot { + x_range, + y_range, + ts, + } = PreparedPlot::prepare(mts)?; + + // Figure out the caption for formatting style for date-times + // Use title override if provided, otherwise use extracted metric name + // Only append common_labels if no title override (since title likely already has labels) + let mut caption = if let Some(title) = &self.title { + title.clone() + } else { + let mut c = name; + if !common_labels.is_empty() { + write!(&mut c, " {common_labels:?}")?; + } + c + }; + + // Format date-times differently depending on the range of date-times being displayed. + // + // If they are all on the same day, then omit the day, and put it in the caption instead + let timezone = + FixedOffset::east_opt(3600 * self.utc_offset_hours).ok_or("invalid timezone")?; + let start_date_naive = x_range.start.with_timezone(&timezone).date_naive(); + let date_format_str = + if start_date_naive == x_range.end.with_timezone(&timezone).date_naive() { + write!(&mut caption, " {start_date_naive}")?; + "%H:%M:%S" + } else { + "%m/%d %H:%M:%S" + }; + + // Add timezone offset to the caption + write!(&mut caption, " UTC{o}", o = self.utc_offset_hours)?; + + // Actually start writing the file + let root_area = BitMapBackend::new(&path, self.drawing_area).into_drawing_area(); + root_area.fill(&self.background)?; + + let mut ctx = ChartBuilder::on(&root_area) + .set_label_area_size(LabelAreaPosition::Left, 100) + .set_label_area_size(LabelAreaPosition::Bottom, 40) + .caption( + caption, + (self.text_font.as_str(), self.caption_size, &self.text_color) + .into_text_style(&root_area), + ) + .build_cartesian_2d(x_range.clone(), y_range.clone())?; + + let text_style = + (self.text_font.as_str(), self.text_size, &self.text_color).into_text_style(&root_area); + + ctx.configure_mesh() + .light_line_style(self.grid) // Dark gray grid lines + .axis_style(self.axis) // White axis lines + .bold_line_style(self.axis) // White bold lines + .label_style(text_style.clone()) + .x_label_formatter(&|x| { + x.with_timezone(&timezone) + .format(date_format_str) + .to_string() + }) + .draw()?; + + if let Some(threshold) = plot_threshold { + let (limit, baseline) = match threshold { + PlotThreshold::GreaterThan(limit) => (limit, y_range.end), + PlotThreshold::LessThan(limit) => (limit, y_range.start), + }; + ctx.draw_series(AreaSeries::new( + [(x_range.start, limit), (x_range.end, limit)], + baseline, + self.threshold_color, + ))?; + } + + // Only show legend if there are multiple series (single series doesn't need a legend) + let show_legend = specific_labels.len() > 1; + + for (idx, (mut metric, vals)) in specific_labels.into_iter().zip(ts.into_iter()).enumerate() + { + let color = &self.data_colors[idx % self.data_colors.len()]; + + let name = metric.remove("__name__").unwrap_or_default(); + let label = format!("{name} {metric:?}"); + + let series = ctx.draw_series(LineSeries::new(vals, color))?; + if show_legend { + series + .label(label) + .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color)); + } + } + + if show_legend { + ctx.configure_series_labels() + .position(SeriesLabelPosition::LowerLeft) + .border_style(self.axis) + .background_style(self.background.mix(0.8)) + .label_font(text_style) + .draw()?; + } + + // Signal any errors that occurred when writing the file + // https://github.com/plotters-rs/plotters?tab=readme-ov-file#faq-list + root_area.present()?; + + Ok(()) + } +} + +struct PreparedPlot { + x_range: Range>, + y_range: Range, + ts: Vec, f64)>>, +} + +impl PreparedPlot { + fn prepare(data: &[impl Borrow>]) -> Result + where + KV: Clone + Debug, + { + let mut x_range = None; + let mut y_range = None; + + let ts: Vec> = data + .iter() + .map(|mts| { + let mts = mts.borrow(); + + mts.values + .iter() + .filter_map(|(k, v)| { + let x = f64_to_datetime(k)?; + let y = v.to_f64()?; + extend_range(&mut x_range, &x); + extend_range(&mut y_range, &y); + + Some((x, y)) + }) + .collect::>() + }) + .collect(); + + let x_range = x_range.ok_or("No data")?; + let y_range = y_range.ok_or("No data")?; + + Ok(Self { + x_range, + y_range, + ts, + }) + } +} + +fn f64_to_datetime(t: &f64) -> Option> { + let seconds = t.trunc() as i64; + let nanoseconds = (t.fract() * 1_000_000_000.0) as u32; + + Utc.timestamp_opt(seconds, nanoseconds).single() +} + +fn extend_range(range: &mut Option>, val: &T) { + if let Some(range) = range.as_mut() { + if range.start > *val { + range.start = val.clone(); + } else if range.end < *val { + range.end = val.clone(); + } + } else { + *range = Some(val.clone()..val.clone()) + } +} diff --git a/prom-client/src/traits.rs b/prom-client/src/traits.rs new file mode 100644 index 0000000..99058b8 --- /dev/null +++ b/prom-client/src/traits.rs @@ -0,0 +1,34 @@ +use crate::{Error, PromResponse}; +use reqwest::{Client, Url}; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt::Debug; + +#[async_trait::async_trait] +pub trait PromRequest: Serialize { + const PATH: &str; + type Output: Clone + Debug + DeserializeOwned; + + async fn send(&self, host: &str) -> Result, Error> + where + KV: Clone + Debug + DeserializeOwned, + { + self.send_with_client(&Client::new(), host).await + } + + async fn send_with_client( + &self, + client: &Client, + host: &str, + ) -> Result, Error> + where + KV: Clone + Debug + DeserializeOwned, + { + let url = Url::parse(host)?.join(Self::PATH)?; + + let resp: PromResponse> = + client.get(url).query(&self).send().await?.json().await?; + + let data = resp.into_result()?; + Ok(data) + } +} diff --git a/signal-gateway/Cargo.toml b/signal-gateway/Cargo.toml new file mode 100644 index 0000000..393479c --- /dev/null +++ b/signal-gateway/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "signal-gateway" +version = "0.1.0" +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +prom-client = { workspace = true } + +chrono = { workspace = true } +circular-buffer = { workspace = true } +conf = { workspace = true } +conf-extra = { workspace = true } +displaydoc = { workspace = true } +dotenvy = { workspace = true } +futures-util = { workspace = true } +http-body-util = { workspace = true } +humantime = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } +jsonrpsee = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true } +rust_decimal = { workspace = true } +rustls = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +syslog_rfc5424 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = { workspace = true } +walkdir = { workspace = true } diff --git a/signal-gateway/src/config.rs b/signal-gateway/src/config.rs new file mode 100644 index 0000000..6d49914 --- /dev/null +++ b/signal-gateway/src/config.rs @@ -0,0 +1,18 @@ +use crate::gateway::GatewayConfig; +use conf::Conf; +use std::net::SocketAddr; + +#[derive(Conf, Debug)] +pub struct Config { + /// If true, just validate config and don't start + #[conf(long)] + pub dry_run: bool, + /// Socket to listen for HTTP requests (GET /health, POST /alert) + #[conf(long, env, default_value = "0.0.0.0:8000")] + pub http_listen_addr: SocketAddr, + /// Socket to listen for UDP messages, in syslog RFC 5424 format + #[conf(long, env, default_value = "0.0.0.0:5424")] + pub udp_listen_addr: SocketAddr, + #[conf(flatten)] + pub gateway: GatewayConfig, +} diff --git a/signal-gateway/src/gateway/log_handler.rs b/signal-gateway/src/gateway/log_handler.rs new file mode 100644 index 0000000..24916eb --- /dev/null +++ b/signal-gateway/src/gateway/log_handler.rs @@ -0,0 +1,387 @@ +use super::{AdminMessage, MultiRateLimiter, Origin, RateThreshold, SourceLocationRateLimiter}; +use crate::human_duration::HumanTMinus; +use chrono::{TimeDelta, Utc}; +use circular_buffer::CircularBuffer; +use conf::Conf; +use serde::Deserialize; +use std::{fmt, time::Duration}; +use syslog_rfc5424::{SyslogMessage, SyslogSeverity}; +use tokio::sync::{Mutex, mpsc::UnboundedSender}; +use tracing::{error, info, warn}; + +/// Reason why an alert was suppressed +enum SuppressionReason { + /// Suppressed by a configured alert rule (with 0-based rule index) + Rule(usize), + /// Suppressed by the source-location rate limiter + SourceLocation { file: String, line: String }, +} + +impl fmt::Display for SuppressionReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SuppressionReason::Rule(idx) => write!(f, "rule[{idx}]"), + SuppressionReason::SourceLocation { file, line } => { + write!(f, "source-location({file}:{line})") + } + } + } +} + +/// Config options related to the log handler, and what log messages it chooses to alert on. +#[derive(Clone, Conf, Debug)] +pub struct LogHandlerConfig { + #[conf(long, env, value_parser = serde_json::from_str)] + pub alert_rate_limits: Vec, + #[conf(long, env, default_value = "10m", value_parser = conf_extra::parse_duration)] + pub overall_alert_limit: Duration, + #[conf(long, env)] + pub format_module: bool, + #[conf(long, env)] + pub format_source_location: bool, + /// Structured data ID for tracing metadata (module, file, line) in syslog messages + #[conf(long, env, default_value = "tracing-meta@64700")] + pub sd_id: String, +} + +/// Specifies both a rate limiting threshold, and criteria for the threshold to apply +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AlertRule { + #[serde(default)] + pub msg_contains: String, + #[serde(default)] + pub module_equals: String, + #[serde(default)] + pub file_equals: String, + #[serde(default)] + pub line_equals: String, + pub threshold: RateThreshold, +} + +impl AlertRule { + /// Check if a syslog message passes the filter defined by this rule + fn eval_filter(&self, syslog_msg: &SyslogMessage, sd_id: &str) -> bool { + if !self.msg_contains.is_empty() && !syslog_msg.msg.contains(&self.msg_contains) { + return false; + } + + if !self.module_equals.is_empty() { + match syslog_msg.sd.find_tuple(sd_id, "module") { + Some(module) if module == self.module_equals.as_str() => {} + _ => return false, + } + } + + if !self.file_equals.is_empty() { + match syslog_msg.sd.find_tuple(sd_id, "file") { + Some(file) if file == self.file_equals.as_str() => {} + _ => return false, + } + } + + if !self.line_equals.is_empty() { + match syslog_msg.sd.find_tuple(sd_id, "line") { + Some(line) if line == self.line_equals.as_str() => {} + _ => return false, + } + } + + true + } +} + +/// The log handler takes log messages from a single origin and decides what +/// to do with them. +/// +/// 1. Store them in a small circular buffer +/// 2. If it is an error, and meets other criteria, trigger an alert, +/// i.e. send a message to admins containing this log and other recent logs. +/// 3. The maximum rate of alerts can also be configured. +/// +/// Additionally, the log handler can format the buffer of recent logs into a string, +/// if requested. +/// +/// Each origin (app + host pair) gets its own LogHandler instance, managed by the Gateway. +#[derive(Debug)] +pub struct LogHandler { + config: LogHandlerConfig, + admin_mq_tx: UnboundedSender, + syslog_buffer: Mutex>, + rate_limiters: Vec<(AlertRule, Mutex)>, + /// Rate limiter keyed by source location (file:line), so different error locations + /// can alert independently without suppressing each other. + overall_limiter: Mutex, + /// True if any configured rule uses the module structured data field. + any_rule_uses_module: bool, + /// True if any configured rule uses the file structured data field. + any_rule_uses_file: bool, + /// True if any configured rule uses the line structured data field. + any_rule_uses_line: bool, +} + +/// Maximum entries in the source-location rate limiter before triggering cleanup +const OVERALL_LIMITER_MAX_ENTRIES: usize = 2000; + +impl LogHandler { + /// Initialize a new log handler + pub fn new(config: LogHandlerConfig, admin_mq_tx: UnboundedSender) -> Self { + let any_rule_uses_module = config + .alert_rate_limits + .iter() + .any(|r| !r.module_equals.is_empty()); + let any_rule_uses_file = config + .alert_rate_limits + .iter() + .any(|r| !r.file_equals.is_empty()); + let any_rule_uses_line = config + .alert_rate_limits + .iter() + .any(|r| !r.line_equals.is_empty()); + let rate_limiters = config + .alert_rate_limits + .iter() + .map(|rule| { + ( + rule.clone(), + Mutex::new(MultiRateLimiter::from(rule.threshold)), + ) + }) + .collect::>(); + let overall_limiter = Mutex::new(SourceLocationRateLimiter::new( + config.overall_alert_limit, + OVERALL_LIMITER_MAX_ENTRIES, + )); + + Self { + config, + admin_mq_tx, + syslog_buffer: Default::default(), + rate_limiters, + overall_limiter, + any_rule_uses_module, + any_rule_uses_file, + any_rule_uses_line, + } + } + + /// Format recent logs into a string + pub async fn format_logs(&self) -> String { + let lk = self.syslog_buffer.lock().await; + + let mut text = format!("{} log messages (newest first):\n", lk.len()); + + // Calculate now once for consistent relative timestamps + let now = Utc::now().timestamp(); + + // Collect and reverse to show newest first + let messages: Vec<_> = lk.iter().collect(); + for syslog_msg in messages.into_iter().rev() { + self.write_syslog_msg(&mut text, syslog_msg, now); + } + text + } + + /// Consume a new syslog message from the given origin + pub async fn handle_syslog_message(&self, mut syslog_msg: SyslogMessage, origin: Origin) { + let suppression_reason = self.check_suppression(&mut syslog_msg).await; + if let Some(reason) = &suppression_reason + && syslog_msg.severity <= SyslogSeverity::SEV_ERR + { + let sev = Self::severity_to_str(syslog_msg.severity); + info!("Suppressed {sev} ({reason}):\n{}", syslog_msg.msg); + } + + // Record this new message. + // Then, if we should alert now, also format the whole buffer to a string, + // and then release the lock. + let formatted_text = { + let mut lk = self.syslog_buffer.lock().await; + lk.push_back(syslog_msg); + if suppression_reason.is_some() { + return; + } + + let mut text = String::default(); + + // Calculate now once for consistent relative timestamps + let now = Utc::now().timestamp(); + + // Iterate in reverse (newest first) without copying + for syslog_msg in lk.iter().rev() { + self.write_syslog_msg(&mut text, syslog_msg, now); + } + lk.clear(); + + text + }; + + if let Err(_err) = self.admin_mq_tx.send(AdminMessage { + origin: Some(origin), + text: formatted_text, + attachment_paths: Default::default(), + summary: None, + }) { + error!("Could not send alert message, queue is closed"); + } + } + + // Convert SyslogSeverity to our own all-caps string that fits in 5 chars + fn severity_to_str(severity: SyslogSeverity) -> &'static str { + match severity { + SyslogSeverity::SEV_EMERG => "EMERG", + SyslogSeverity::SEV_ALERT => "ALERT", + SyslogSeverity::SEV_CRIT => "CRIT", + SyslogSeverity::SEV_ERR => "ERROR", + SyslogSeverity::SEV_WARNING => "WARN", + SyslogSeverity::SEV_NOTICE => "NOTE", + SyslogSeverity::SEV_INFO => "INFO", + SyslogSeverity::SEV_DEBUG => "DEBUG", + } + } + + // Format a syslog message into a Writer, followed by \n, and using any config options to do so + fn write_syslog_msg( + &self, + mut writer: impl std::fmt::Write, + syslog_msg: &SyslogMessage, + now: i64, + ) { + let sev = Self::severity_to_str(syslog_msg.severity); + let msg = &syslog_msg.msg; + + // Format relative timestamp if available + let time_str = if let Some(ts) = syslog_msg.timestamp { + let diff_secs = now.saturating_sub(ts); + HumanTMinus(TimeDelta::seconds(diff_secs)).to_string() + } else { + "T-?".to_owned() + }; + + // Extract metadata from structured data if configured + let sd_id = &self.config.sd_id; + let mut metadata_parts = Vec::new(); + + if self.config.format_module + && let Some(module) = syslog_msg.sd.find_tuple(sd_id, "module") + { + metadata_parts.push(module.to_string()); + } + + if self.config.format_source_location { + let file_opt = syslog_msg.sd.find_tuple(sd_id, "file"); + let line_opt = syslog_msg.sd.find_tuple(sd_id, "line"); + + let location = if let Some(file) = file_opt { + // Strip /home/{username}/ prefix if present + let trimmed_file = strip_prefix_and_one_slash(file, "/home/"); + // Strip .cargo/registry/src/{hash}/ if present + let trimmed_file = strip_prefix_and_one_slash(trimmed_file, ".cargo/registry/src/"); + + if let Some(line) = line_opt { + format!("{trimmed_file}:{line}") + } else { + format!("{trimmed_file}:?") + } + } else { + // No file present, use "?" even if line is present + "?".to_owned() + }; + + metadata_parts.push(location); + } + + // Format: "ERROR T-10s [foo bar.rs:42]: message" + // Pad severity to 5 chars (left-aligned), time to 8 chars (right-aligned) + let result = if metadata_parts.is_empty() { + writeln!(writer, "{:<5} {:>8}: {}", sev, time_str, msg) + } else { + let metadata = metadata_parts.join(" "); + writeln!(writer, "{:<5} {:>8} [{}]: {}", sev, time_str, metadata, msg) + }; + + if let Err(err) = result { + error!("Couldn't write syslog message ({err}): {sev}: {msg}"); + } + } + + /// Check if an alert should be suppressed for a given error message. + /// + /// Returns `None` if the alert should fire, or `Some(reason)` if suppressed. + async fn check_suppression(&self, syslog_msg: &mut SyslogMessage) -> Option { + let ts_sec = *syslog_msg + .timestamp + .get_or_insert_with(|| Utc::now().timestamp()); + + let high_severity = syslog_msg.severity <= SyslogSeverity::SEV_ERR; + if !high_severity { + // Low severity messages are always "suppressed" (not alerted on) + // but we don't need to log a reason for this + return Some(SuppressionReason::Rule(usize::MAX)); + } + + let sd_id = &self.config.sd_id; + + // Warn if rules expect structured data but the message doesn't have it + let has_module = syslog_msg.sd.find_tuple(sd_id, "module").is_some(); + let has_file = syslog_msg.sd.find_tuple(sd_id, "file").is_some(); + let has_line = syslog_msg.sd.find_tuple(sd_id, "line").is_some(); + if (self.any_rule_uses_module && !has_module) + || (self.any_rule_uses_file && !has_file) + || (self.any_rule_uses_line && !has_line) + { + warn!( + "Error message missing structured data (sd_id={sd_id}), filtering rules may not work: {syslog_msg:#?}" + ); + } + + // Check each configured rule - track which rule suppressed the alert + // Note: we check all rules even if one already suppressed, to update all rate limiters + let mut suppressed_by_rule: Option = None; + for (idx, (filter, limiter)) in self.rate_limiters.iter().enumerate() { + if filter.eval_filter(syslog_msg, sd_id) && !limiter.lock().await.evaluate(ts_sec) { + suppressed_by_rule.get_or_insert(idx); + } + } + if let Some(idx) = suppressed_by_rule { + return Some(SuppressionReason::Rule(idx)); + } + + // Extract source location for per-location rate limiting + let file = syslog_msg + .sd + .find_tuple(sd_id, "file") + .map_or("?", |s| s.as_str()); + let line = syslog_msg + .sd + .find_tuple(sd_id, "line") + .map_or("?", |s| s.as_str()); + + if !self + .overall_limiter + .lock() + .await + .evaluate(file, line, ts_sec) + { + return Some(SuppressionReason::SourceLocation { + file: file.to_owned(), + line: line.to_owned(), + }); + } + + None // Alert should fire + } +} + +// Strip a prefix, then find the first remaining slash and skip up to that as well. +fn strip_prefix_and_one_slash<'a>(target: &'a str, prefix: &str) -> &'a str { + let Some(target) = target.strip_prefix(prefix) else { + return target; + }; + + if let Some((_, after)) = target.split_once('/') { + after + } else { + target + } +} diff --git a/signal-gateway/src/gateway/mod.rs b/signal-gateway/src/gateway/mod.rs new file mode 100644 index 0000000..22d08ec --- /dev/null +++ b/signal-gateway/src/gateway/mod.rs @@ -0,0 +1,857 @@ +use crate::{ + http::{AlertMessage, Status}, + jsonrpc::{Envelope, RpcClient, RpcClientError, SignalMessage, connect_tcp}, + plotter::{Plotter, PlotterConfig}, +}; +use conf::{Conf, Subcommands}; +use futures_util::FutureExt; +use http_body_util::BodyExt; +use hyper::{Method, Request, Response, StatusCode, body::Incoming}; +use prom_client::{AlertStatus, ExtractLabels}; +//use jsonrpsee::async_client::{Client as JsonRpcClient, Error as JsonRpcError}; +use chrono::Utc; +use std::{collections::HashMap, error::Error, fmt::Write, net::SocketAddr, time::Duration}; +use syslog_rfc5424::SyslogMessage; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + join, + net::TcpStream, + sync::{ + Mutex, RwLock, + mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}, + }, + time::timeout, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +mod log_handler; +use log_handler::{LogHandler, LogHandlerConfig}; + +mod rate_limiter; +use rate_limiter::{MultiRateLimiter, RateThreshold, SourceLocationRateLimiter}; + +#[derive(Conf, Debug)] +pub struct GatewayConfig { + #[conf(long, env, default_value = "127.0.0.1:7583")] + pub signal_cli_tcp_addr: SocketAddr, + #[conf(long, env)] + pub signal_account: String, + #[conf(long, env)] + pub cbmm_tcp_addr: String, + #[conf(long, env, default_value = "5s", value_parser = conf_extra::parse_duration)] + pub cbmm_timeout: Duration, + #[conf(repeat, long, env)] + pub admin_uuid: Vec, + #[conf(flatten)] + pub plotter: Option, + #[conf(flatten)] + pub log_handler: LogHandlerConfig, +} + +/// Wrapper for parsing gateway commands +#[derive(Clone, Debug, Conf)] +struct GatewayCommandWrapper { + #[conf(subcommands)] + command: GatewayCommand, +} + +/// Commands that can be sent to the gateway (prefixed with /) +#[derive(Clone, Debug, Subcommands)] +enum GatewayCommand { + /// Show recent log messages + #[conf(name = "log", alias = "LOG")] + Log { + /// Optional filter: show only origins where app or host contains this string + #[conf(pos)] + filter: Option, + }, + /// Query prometheus for current values + #[conf(name = "query", alias = "QUERY")] + Query { + /// PromQL query expression + #[conf(pos)] + query: String, + }, + /// Plot a prometheus query over time + #[conf(name = "plot", alias = "PLOT")] + Plot { + /// PromQL query expression + #[conf(pos)] + query: String, + /// Duration to plot (e.g., 1h, 24h) + #[conf(long, short = 'd', default_value = "1h", value_parser = conf_extra::parse_duration)] + duration: Duration, + }, + /// List series matching label patterns + #[conf(name = "series", alias = "SERIES")] + Series { + /// Label matchers (e.g., __name__=~".*requests.*") + #[conf(repeat, pos)] + matchers: Vec, + }, + /// List label names matching patterns + #[conf(name = "labels", alias = "LABELS")] + Labels { + /// Label matchers + #[conf(repeat, pos)] + matchers: Vec, + }, + /// Show current alerts from prometheus + #[conf(name = "alerts", alias = "ALERTS")] + Alerts, +} + +/// Parse a gateway command from a string (with or without leading /) +fn parse_gateway_command(s: &str) -> Result { + // Remove leading slash if present + let s = s.strip_prefix('/').unwrap_or(s).trim(); + + if s.is_empty() { + return Err("Empty command".to_string()); + } + + // Parse using Conf, treating the input as command line arguments + // Prepend a dummy program name since Conf expects argv[0] + let args = std::iter::once("gateway") + .chain(s.split_whitespace()) + .collect::>(); + + GatewayCommandWrapper::try_parse_from::<&str, &str, &str>(args, vec![]) + .map(|wrapper| wrapper.command) + .map_err(|e| e.to_string()) +} + +/// Identifies the source of log messages (app name + host). +/// Used to separate log buffers and rate limiters per source. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Origin { + pub app: String, + pub host: String, +} + +impl std::fmt::Display for Origin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.app, self.host) + } +} + +impl From<&SyslogMessage> for Origin { + fn from(msg: &SyslogMessage) -> Self { + Self { + app: msg.appname.clone().unwrap_or_default(), + host: msg.hostname.clone().unwrap_or_default(), + } + } +} + +impl Origin { + /// Check if this origin matches a filter string. + /// + /// If the filter contains '@', it is split on the first '@': + /// - The part before '@' must be a substring of `app` + /// - The part after '@' must be a substring of `host` + /// + /// If the filter does not contain '@', it matches if either `app` or `host` + /// contains the filter string. + pub fn matches_filter(&self, filter: &str) -> bool { + if let Some((app_filter, host_filter)) = filter.split_once('@') { + self.app.contains(app_filter) && self.host.contains(host_filter) + } else { + self.app.contains(filter) || self.host.contains(filter) + } + } +} + +/// A message queued to be sent to all admins. +/// This is generally an alert message, which may have attached images. +#[derive(Clone, Debug, Default)] +struct AdminMessage { + /// The origin of the message (app + host), if from syslog + origin: Option, + text: String, + attachment_paths: Vec, + /// Short summary for logging (e.g., alert names for prometheus). + /// If None, the consumer will use a truncated slice of `text` for logging. + summary: Option, +} + +/// The gateway manages sending messages to signal-cli and receiving messages from signal-cli. +/// It maintains a queue of messages to be sent to all admins, generated by alerts etc. +/// It also subscribes to messages received from signal and processes them one-by-one, possibly +/// making TCP request to cbmm to handle them. +/// +/// This is the only task that communicates directly with signal-cli, and by design it linearizes +/// all interaction, which prevents any possible races. +/// +/// The gateway also maintains a buffer of at most 64 syslog messages that it has received. +/// If an error occurs, that message and the previous messages in the buffer are sent to signal admins, +/// and then the buffer is purged. This serves as a minimal log-aggregation and alerting system. +pub struct Gateway { + config: GatewayConfig, + admin_mq_tx: UnboundedSender, + admin_mq_rx: Mutex>, + token: CancellationToken, + plotter: Option, + /// Log handlers keyed by origin (app + host). Lazily created when first message from an origin arrives. + log_handlers: RwLock>, +} + +impl Gateway { + pub async fn new(config: GatewayConfig, token: CancellationToken) -> Self { + let (admin_mq_tx, admin_mq_rx) = unbounded_channel(); + + let plotter = config + .plotter + .as_ref() + .map(|plotter_config| Plotter::new(plotter_config.clone())); + + Self { + config, + admin_mq_tx, + admin_mq_rx: Mutex::new(admin_mq_rx), + token, + plotter, + log_handlers: RwLock::new(HashMap::new()), + } + } + + pub async fn run(&self) { + loop { + if self.token.is_cancelled() { + return; + } + match connect_tcp(&self.config.signal_cli_tcp_addr).await { + Err(err) => { + error!( + "Could not connect to signal_cli @ ({}): {err}", + self.config.signal_cli_tcp_addr + ); + tokio::time::sleep(Duration::from_secs(5)).await; + } + Ok(client) => { + if let Err(err) = self.do_run(&client).await { + error!("Error with signal cli, reconnecting: {err}"); + tokio::time::sleep(Duration::from_secs(5)).await; + } else { + continue; + } + } + } + } + } + + async fn do_run(&self, signal_cli: &impl RpcClient) -> Result<(), RpcClientError> { + let mut admin_mq_rx = self + .admin_mq_rx + .try_lock() + .expect("Mutex should not be contended"); + let mut signal_rx = signal_cli + .subscribe_receive(Some(self.config.signal_account.clone())) + .await?; + + loop { + tokio::select! { + _ = self.token.cancelled() => { + info!("Stop requested"); + return Ok(()); + }, + outbound_admin_msg = admin_mq_rx.recv() => { + if let Some(msg) = outbound_admin_msg { + // Log summary, or first 500 bytes of text if no summary provided + let summary = msg.summary.as_deref().unwrap_or_else(|| { + let len = msg.text.len().min(500); + &msg.text[..len] + }); + info!("Sending alert: {summary}"); + // Prepend origin line if present + let message = if let Some(origin) = &msg.origin { + format!("[{origin}]\n{}", msg.text) + } else { + msg.text + }; + SignalMessage { + sender: self.config.signal_account.clone(), + recipient: self.config.admin_uuid.clone(), + message, + attachments: msg.attachment_paths, + }.send(signal_cli).await?; + } else { + warn!("admin_mq_rx is closed, halting service"); + self.token.cancel(); + return Ok(()); + } + }, + signal_msg = signal_rx.next() => { + match signal_msg { + None => { + info!("Signal Rx: stream closed"); + return Ok(()); + }, + Some(Err(err)) => { + error!("Signal Rx: {err}"); + return Err(RpcClientError::ParseError(err)); + } + Some(Ok(msg)) => { + //info!("Signal Rx: {msg:?}"); + if msg.envelope.data_message.is_none() { + debug!("Ignoring message which was not a data message: {msg:?}"); + continue; + } + + if !self.config.admin_uuid.contains(&msg.envelope.source_uuid) { + warn!("Ignoring message from non-admin: {msg:?}"); + continue; + } + + let (resp, _) = join!(self.handle_signal_admin_message(&msg.envelope), + msg.envelope.send_read_receipt(signal_cli, &self.config.signal_account).map(|result| { + if let Err(err) = result { + warn!("Couldn't send read receipt: {err}"); + } + })); + let (message, attachments) = resp.unwrap_or_else( + |(code, msg)| { + let text = format!("{code}: {msg}"); + error!("(cbmm) {text}"); + (text, vec![]) + } + ); + + SignalMessage { + sender: self.config.signal_account.clone(), + recipient: vec![msg.envelope.source_uuid.clone()], + message, + attachments, + }.send(signal_cli).await?; + } + } + } + } + } + } + + // Returns Err in case of a timeout + // Returns Ok when success or error text is generated + async fn handle_signal_admin_message( + &self, + msg: &Envelope, + ) -> Result<(String, Vec), (u16, Box)> { + let data = msg.data_message.as_ref().unwrap(); + + // Admin messages starting with / are handled by gateway + // Other messages are forwarded to cbmm + if data.message.starts_with("/") { + // Parse the command using conf + let cmd = parse_gateway_command(&data.message).map_err(|err| (400, err.into()))?; + + self.handle_gateway_command(cmd).await + } else { + // Connect to cbmm. Note that we could use a keep-alive strategy here maybe... + let mut cbmm_stream = timeout( + self.config.cbmm_timeout, + TcpStream::connect(&self.config.cbmm_tcp_addr), + ) + .await + .map_err(format_err("connecting", 504))? + .map_err(format_err("connecting", 502))?; + + timeout( + self.config.cbmm_timeout, + cbmm_stream.write_all(data.message.as_bytes()), + ) + .await + .map_err(format_err("writing", 504))? + .map_err(format_err("writing", 502))?; + + let _ = cbmm_stream.shutdown().await; + + // Wrap as BufReader so that we can use "read_until" which simplifies things + let mut reader = BufReader::new(cbmm_stream); + let mut buf = vec![]; + timeout(self.config.cbmm_timeout, reader.read_until(b'\r', &mut buf)) + .await + .map_err(format_err("reading", 504))? + .map_err(format_err("reading", 502))?; + + let s = str::from_utf8(&buf).map_err(format_err("utf8", 502))?; + let text = s.trim().to_owned(); + Ok((text, vec![])) + } + } + + // Handler function that processes incoming http requests (push's from alertmanager expected) + pub async fn handle_http_request( + &self, + req: Request, + ) -> Result, String> { + info!( + "Received http request: {} {} (version: {:?})", + req.method(), + req.uri().path(), + req.version() + ); + + fn ok_resp() -> Response { + Response::new("OK".into()) + } + fn err_resp(code: StatusCode, text: impl Into) -> Response { + let mut resp = Response::new(text.into()); + *resp.status_mut() = code; + resp + } + + match (req.method(), req.uri().path()) { + (&Method::GET, "/") => Ok(ok_resp()), + (&Method::GET, "/health") => Ok(ok_resp()), + (&Method::POST, "/alert") => { + let v = req + .into_body() + .collect() + .await + .map_err(|err| format!("When reading body bytes: {err}"))? + .to_bytes() + .to_vec(); + + if let Err((code, msg)) = self.handle_post_alert(&v).await { + Ok(err_resp(code, msg)) + } else { + Ok(ok_resp()) + } + } + _ => Ok(err_resp( + StatusCode::NOT_FOUND, + format!("Not found '{} {}'", req.method(), req.uri().path()), + )), + } + } + + async fn handle_gateway_command( + &self, + cmd: GatewayCommand, + ) -> Result<(String, Vec), (u16, Box)> { + match cmd { + GatewayCommand::Log { filter } => { + let handlers = self.log_handlers.read().await; + if handlers.is_empty() { + return Ok(("No log sources registered yet".to_string(), vec![])); + } + let mut text = String::new(); + for (origin, handler) in handlers.iter() { + // Apply filter if present + if let Some(ref f) = filter + && !origin.matches_filter(f) + { + continue; + } + writeln!(&mut text, "=== [{origin}] ===").unwrap(); + text.push_str(&handler.format_logs().await); + text.push('\n'); + } + if text.is_empty() { + return Ok(("No matching log sources".to_string(), vec![])); + } + Ok((text, vec![])) + } + GatewayCommand::Query { query } => { + let plotter = self + .plotter + .as_ref() + .ok_or_else(|| (500, "prometheus was not configured".into()))?; + + match plotter.oneoff_query(query).await { + Ok(( + ExtractLabels { + name, + common_labels, + specific_labels, + }, + ts, + )) => { + let mut text = format!("{name} {common_labels:?}\n"); + + for (mut sl, maybe_val) in specific_labels.into_iter().zip(ts.into_iter()) { + let name = sl.remove("__name__").unwrap_or_default(); + let label = format!("{name} {sl:?}"); + + // TODO: Include timestamp? + let val = maybe_val + .map(|(_time, val)| val.to_string()) + .unwrap_or_else(|| "-".to_string()); + if let Err(err) = writeln!(&mut text, "\t{label}\t\t{val}") { + return Err((500, err.into())); + } + } + + Ok((text, vec![])) + } + Err(err) => Err((500, err)), + } + } + GatewayCommand::Plot { query, duration } => { + let plotter = self + .plotter + .as_ref() + .ok_or_else(|| (500, "prometheus was not configured".into()))?; + + plotter.purge_old_plots(); + match plotter.create_oneoff_plot(query.clone(), duration).await { + Ok(filename) => Ok((query, vec![filename])), + Err(err) => Err((500, err)), + } + } + GatewayCommand::Series { matchers } => { + let plotter = self + .plotter + .as_ref() + .ok_or_else(|| (500, "prometheus was not configured".into()))?; + + let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect(); + match plotter.series(&matcher_refs).await { + Ok(data) => { + let mut text = data.iter().fold(String::default(), |mut buf, kv| { + writeln!(&mut buf, "{kv:?}").unwrap(); + buf + }); + if text.is_empty() { + text = "no matches".into(); + } + Ok((text, vec![])) + } + Err(err) => Err((500, err)), + } + } + GatewayCommand::Labels { matchers } => { + let plotter = self + .plotter + .as_ref() + .ok_or_else(|| (500, "prometheus was not configured".into()))?; + + let matcher_refs: Vec<&str> = matchers.iter().map(|s| s.as_str()).collect(); + match plotter.labels(&matcher_refs).await { + Ok(data) => { + let mut text = data.iter().fold(String::default(), |mut buf, l| { + writeln!(&mut buf, "{l}").unwrap(); + buf + }); + if text.is_empty() { + text = "no matches".into(); + } + Ok((text, vec![])) + } + Err(err) => Err((500, err)), + } + } + GatewayCommand::Alerts => { + let plotter = self + .plotter + .as_ref() + .ok_or_else(|| (500, "prometheus was not configured".into()))?; + + match plotter.alerts().await { + Ok(data) => { + let now = Utc::now(); + let mut text = data.iter().fold(String::default(), |mut buf, alert| { + let symbol = match alert.state { + AlertStatus::Pending => "🟡", + AlertStatus::Firing => "🔴", + AlertStatus::Resolved => "🟢", + }; + let since = { + let dur = (now - alert.active_at).to_std().unwrap_or_default(); + // reduce precision to at most seconds + let mut secs = dur.as_secs(); + // if the duration is more than an hour, then reduce precision to minutes + if secs > 3600 { + secs -= secs % 60; + } + humantime::format_duration(Duration::new(secs, 0)) + }; + let annotations = &alert.annotations; + let labels = &alert.labels; + + writeln!(&mut buf, "{symbol} {since} {annotations:?} {labels:?}") + .unwrap(); + buf + }); + if text.is_empty() { + text = "no alerts".into(); + } + Ok((text, vec![])) + } + Err(err) => Err((500, err)), + } + } + } + } + + async fn handle_post_alert(&self, body_bytes: &[u8]) -> Result<(), (StatusCode, &'static str)> { + let body_text = str::from_utf8(body_bytes).map_err(|err| { + warn!("When reading body bytes: {err}"); + (StatusCode::BAD_REQUEST, "Request body was not utf-8") + })?; + + let alert_msg: AlertMessage = serde_json::from_str(body_text).map_err(|err| { + error!("Could not parse json: {err}:\n{body_text}"); + (StatusCode::BAD_REQUEST, "Invalid Json") + })?; + + let text = self + .format_alert_text(&alert_msg) + .unwrap_or_else(|err| format!("error formatting alert text: {err}:\n{alert_msg:#?}")); + + let mut attachment_paths = vec![]; + if let Some(plotter) = self.plotter.as_ref() { + plotter.purge_old_plots(); + for alert in alert_msg.alerts.iter() { + match plotter.create_alert_plot(alert).await { + Ok(path) => { + attachment_paths.push(path); + } + Err(err) => { + error!("Could not format plot: {err} for {alert:#?}"); + } + } + } + } + + // Build summary: status sigils followed by alert names + let summary = alert_msg + .alerts + .iter() + .map(|alert| { + let symbol = match alert.status { + Status::Firing => "🔴", + Status::Resolved => "🟢", + }; + let name = alert + .labels + .get("alertname") + .map(|s| s.as_str()) + .unwrap_or("?"); + format!("{symbol}{name}") + }) + .collect::>() + .join(" "); + + self.admin_mq_tx + .send(AdminMessage { + origin: None, // Prometheus alerts don't have a syslog origin + text, + attachment_paths, + summary: Some(summary), + }) + .map_err(|_err| { + error!("Could not send alert message, queue is closed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Can't send signal msg right now, queue is closed", + ) + }) + } + + fn format_alert_text(&self, msg: &AlertMessage) -> Result { + let mut text = "Alert:\n".to_owned(); + let now = Utc::now(); + + for alert in msg.alerts.iter() { + let symbol = match alert.status { + Status::Firing => "🔴", + Status::Resolved => "🟢", + }; + let since = { + let dur = (now - alert.starts_at).to_std().unwrap_or_default(); + // reduce precision to at most seconds + let mut secs = dur.as_secs(); + // if the duration is more than an hour, then reduce precision to minutes + if secs > 3600 { + secs -= secs % 60; + } + humantime::format_duration(Duration::new(secs, 0)) + }; + let name = alert + .annotations + .get("summary") + .or_else(|| alert.labels.get("alertname")) + .map(|s| s.as_str()) + .unwrap_or("?"); + + let expr = match alert.parse_expr_from_generator_url() { + Ok(expr) => expr, + Err(err) => { + error!( + "Couldn't parse generator url {}: {err}", + alert.generator_url + ); + String::default() + } + }; + + writeln!(&mut text, "{symbol}: ({since}) '{name}' {expr}") + .map_err(|err| format!("formatting error: {err}"))?; + } + + Ok(text) + } + + pub async fn handle_syslog_message(&self, syslog_msg: SyslogMessage) { + let origin = Origin::from(&syslog_msg); + + // Try to get existing handler with read lock first + { + let handlers = self.log_handlers.read().await; + if let Some(handler) = handlers.get(&origin) { + handler.handle_syslog_message(syslog_msg, origin).await; + return; + } + } + + // Handler doesn't exist, need to create one with write lock + let mut handlers = self.log_handlers.write().await; + // Double-check in case another task created it while we were waiting for the write lock + let handler = handlers.entry(origin.clone()).or_insert_with(|| { + info!("Creating new log handler for origin: {origin}"); + LogHandler::new(self.config.log_handler.clone(), self.admin_mq_tx.clone()) + }); + handler.handle_syslog_message(syslog_msg, origin).await; + } +} + +impl Drop for Gateway { + fn drop(&mut self) { + self.token.cancel(); + } +} + +// Returns a lambda that expresses an error as a (u16, String) with given context info +fn format_err( + context: &'static str, + code: u16, +) -> impl Fn(E) -> (u16, Box) { + move |err: E| -> (u16, Box) { (code, format!("{context}: {err}").into()) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_gateway_command() { + // Test log command without filter + let cmd = parse_gateway_command("/log").unwrap(); + assert!(matches!(cmd, GatewayCommand::Log { filter: None })); + + // Test log command with filter + let cmd = parse_gateway_command("/log myapp").unwrap(); + if let GatewayCommand::Log { filter } = cmd { + assert_eq!(filter, Some("myapp".to_string())); + } else { + panic!("Expected Log command"); + } + + // Test uppercase log command + let cmd = parse_gateway_command("/LOG").unwrap(); + assert!(matches!(cmd, GatewayCommand::Log { filter: None })); + + // Test query command + let cmd = parse_gateway_command("/query test_metric").unwrap(); + if let GatewayCommand::Query { query } = cmd { + assert_eq!(query, "test_metric"); + } else { + panic!("Expected Query command"); + } + + // Test query command (uppercase) + let cmd = parse_gateway_command("/QUERY test_metric").unwrap(); + if let GatewayCommand::Query { query } = cmd { + assert_eq!(query, "test_metric"); + } else { + panic!("Expected Query command"); + } + + // Test plot command with default duration + let cmd = parse_gateway_command("/plot my_query").unwrap(); + if let GatewayCommand::Plot { query, duration } = cmd { + assert_eq!(query, "my_query"); + assert_eq!(duration, Duration::from_secs(60 * 60)); + } else { + panic!("Expected Plot command"); + } + + // Test plot command with custom duration + let cmd = parse_gateway_command("/plot my_query -d 24h").unwrap(); + if let GatewayCommand::Plot { query, duration } = cmd { + assert_eq!(query, "my_query"); + assert_eq!(duration, Duration::from_secs(24 * 60 * 60)); + } else { + panic!("Expected Plot command"); + } + + // Test series command with matchers + let cmd = parse_gateway_command("/series metric1 metric2").unwrap(); + if let GatewayCommand::Series { matchers } = cmd { + assert_eq!(matchers, vec!["metric1", "metric2"]); + } else { + panic!("Expected Series command"); + } + + // Test labels command + let cmd = parse_gateway_command("/labels foo bar").unwrap(); + if let GatewayCommand::Labels { matchers } = cmd { + assert_eq!(matchers, vec!["foo", "bar"]); + } else { + panic!("Expected Labels command"); + } + + // Test alerts command + let cmd = parse_gateway_command("/alerts").unwrap(); + assert!(matches!(cmd, GatewayCommand::Alerts)); + + // Test alerts command (uppercase) + let cmd = parse_gateway_command("/ALERTS").unwrap(); + assert!(matches!(cmd, GatewayCommand::Alerts)); + + // Test without leading slash + let cmd = parse_gateway_command("log").unwrap(); + assert!(matches!(cmd, GatewayCommand::Log { filter: None })); + + // Test empty command + assert!(parse_gateway_command("/").is_err()); + assert!(parse_gateway_command("").is_err()); + } + + #[test] + fn test_origin_matches_filter() { + let origin = Origin { + app: "muad-dib".to_string(), + host: "tokyo-server".to_string(), + }; + + // Without @: matches if app OR host contains the string + assert!(origin.matches_filter("muad")); + assert!(origin.matches_filter("dib")); + assert!(origin.matches_filter("tokyo")); + assert!(origin.matches_filter("server")); + assert!(!origin.matches_filter("paris")); + + // With @: app must contain first part AND host must contain second part + assert!(origin.matches_filter("muad@tokyo")); + assert!(origin.matches_filter("dib@server")); + assert!(origin.matches_filter("muad-dib@tokyo-server")); + assert!(!origin.matches_filter("muad@paris")); + assert!(!origin.matches_filter("other@tokyo")); + + // Empty parts with @ + assert!(origin.matches_filter("@tokyo")); // empty app filter matches any app + assert!(origin.matches_filter("muad@")); // empty host filter matches any host + assert!(origin.matches_filter("@")); // both empty, matches everything + + // Edge case: filter matches the @ in the format but origin has no @ + let origin2 = Origin { + app: "app".to_string(), + host: "host".to_string(), + }; + assert!(origin2.matches_filter("app@host")); + assert!(!origin2.matches_filter("app@other")); + } +} diff --git a/signal-gateway/src/gateway/rate_limiter.rs b/signal-gateway/src/gateway/rate_limiter.rs new file mode 100644 index 0000000..794fbda --- /dev/null +++ b/signal-gateway/src/gateway/rate_limiter.rs @@ -0,0 +1,288 @@ +use serde::Deserialize; +use std::{ + collections::HashMap, + str::FromStr, + sync::atomic::{AtomicI64, Ordering}, + time::Duration, +}; + +/// Represents a rate threshold, expressed as a string in the format: +/// +/// * `1 / 10s` +/// * `2 / 5m` +/// * `3 / 1h` +/// * `> 1 / 10s` +/// * `>= 2 / 10s` +/// +/// When the comparator is omitted, it is treated as `>=` +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(try_from = "String")] +pub struct RateThreshold { + pub times: usize, + pub duration: Duration, +} + +impl FromStr for RateThreshold { + type Err = String; + + fn from_str(s: &str) -> Result { + let Some((first, second)) = s.trim().split_once('/') else { + return Err("missing '/' character in rate threshold".into()); + }; + + let duration = conf_extra::parse_duration(second.trim())?; + + let first = first.trim(); + let maybe_mid = first.as_bytes().iter().position(|b| b.is_ascii_digit()); + let (comparator, num) = if let Some(mid) = maybe_mid { + first.split_at(mid) + } else { + ("", first) + }; + + let is_greater_equal = match comparator.trim() { + ">" => false, + ">=" | "=>" | "" => true, + _ => return Err(format!("Unexpected comparator format: {comparator}")), + }; + + let num = num.trim(); + let mut times: usize = num + .parse() + .map_err(|err| format!("invalid number {num}: {err}"))?; + + if !is_greater_equal { + times += 1; + } + + if times == 0 { + return Err("Invalid threshold, times must be > 0".into()); + } + + Ok(RateThreshold { times, duration }) + } +} + +impl TryFrom for RateThreshold { + type Error = ::Err; + fn try_from(s: String) -> Result { + RateThreshold::from_str(&s) + } +} + +/// A rate limiter containing a single counter, and a minimum time window for the next event to pass +#[allow(dead_code)] +#[derive(Debug, Default)] +pub struct SimpleRateLimiter { + last_timestamp: AtomicI64, + window: i64, +} + +#[allow(dead_code)] +impl SimpleRateLimiter { + pub fn new(window: Duration) -> Self { + Self { + last_timestamp: Default::default(), + window: window.as_secs().try_into().unwrap(), + } + } + + /// Check if a particular new timestamp passes the limit. This also updates the last-known timestamp. + pub fn evaluate(&self, ts_sec: i64) -> bool { + let last_ts = self.last_timestamp.load(Ordering::SeqCst); + let rate_limited = ts_sec - last_ts < self.window; + if !rate_limited && ts_sec > last_ts { + // If this is called concurrently, guarantee that we keep going + // until the max value is stored at self.last_timestamp, + // so self.last_timestamp is "eventually" only monotonically increasing. + store_max(ts_sec, &self.last_timestamp); + } + !rate_limited + } +} + +#[allow(dead_code)] +fn store_max(val: i64, at: &AtomicI64) { + let prev = at.swap(val, Ordering::SeqCst); + if prev > val { + store_max(prev, at) + } +} + +/// A rate limiter that tracks alerts per source location (file:line). +/// +/// This allows different error locations to alert independently, preventing one noisy +/// error from suppressing alerts from completely different code paths. +#[derive(Debug)] +pub struct SourceLocationRateLimiter { + /// Maps (file, line) -> last alert timestamp + last_timestamps: HashMap<(String, String), i64>, + /// The rate limiting window in seconds + window: i64, + /// Maximum entries before triggering cleanup + max_entries: usize, +} + +impl SourceLocationRateLimiter { + pub fn new(window: Duration, max_entries: usize) -> Self { + Self { + last_timestamps: HashMap::new(), + window: window.as_secs().try_into().unwrap(), + max_entries, + } + } + + /// Check if an error from this source location should trigger an alert. + /// + /// Returns true if the alert should fire (not rate-limited), false if suppressed. + /// Updates the stored timestamp if the alert fires. + pub fn evaluate(&mut self, file: &str, line: &str, ts_sec: i64) -> bool { + let key = (file.to_owned(), line.to_owned()); + + if let Some(&last_ts) = self.last_timestamps.get(&key) + && ts_sec - last_ts < self.window + { + return false; // Rate limited + } + + // Alert should fire - update timestamp + self.last_timestamps.insert(key, ts_sec); + + // Clean up if we've exceeded max entries + if self.last_timestamps.len() > self.max_entries { + self.cleanup(ts_sec); + } + + true + } + + /// Remove entries older than the window + fn cleanup(&mut self, now: i64) { + self.last_timestamps + .retain(|_, &mut ts| now - ts < self.window); + } +} + +/// Implements rate-limiting criteria such as 'at least n in the last w seconds' +#[derive(Debug)] +pub struct MultiRateLimiter { + /// Records the last n events + timestamps: Vec, + /// Invariant: Always points to the oldest of the last n timestamps in the buffer + idx: usize, + /// The length of the window (in seconds) + window: i64, +} + +impl MultiRateLimiter { + pub fn new(num: usize, window: Duration) -> Self { + Self { + idx: 0, + timestamps: vec![Default::default(); num], + window: window.as_secs().try_into().unwrap(), + } + } + + /// Check if a particular new timestamp passes the limit. This also updates the last-known timestamp. + /// + /// Note: Assumes that new_timestamp is monotonically increasing, otherwise it might not work right. + pub fn evaluate(&mut self, new_timestamp: i64) -> bool { + let oldest = self.timestamps[self.idx]; + if oldest >= new_timestamp { + return false; + } + self.timestamps[self.idx] = new_timestamp; + self.idx += 1; + self.idx %= self.timestamps.len(); + let next_oldest = self.timestamps[self.idx]; + // If the next oldest is within 'window' of the new timestamp, + // then all of the most recent n are. Otherwise, at most n-1 of the most recent are. + next_oldest + self.window >= new_timestamp + } +} + +impl From for MultiRateLimiter { + fn from(src: RateThreshold) -> Self { + Self::new(src.times, src.duration) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_rate_threshold() { + let threshold = RateThreshold::from_str("1/10s").unwrap(); + assert_eq!(threshold.times, 1); + assert_eq!(threshold.duration, Duration::from_secs(10)); + let threshold = RateThreshold::from_str("1 / 10s").unwrap(); + assert_eq!(threshold.times, 1); + assert_eq!(threshold.duration, Duration::from_secs(10)); + + let threshold = RateThreshold::from_str("2 / 5m").unwrap(); + assert_eq!(threshold.times, 2); + assert_eq!(threshold.duration, Duration::from_secs(300)); + + let threshold = RateThreshold::from_str("> 3 / 10m").unwrap(); + assert_eq!(threshold.times, 4); + assert_eq!(threshold.duration, Duration::from_secs(600)); + + let threshold = RateThreshold::from_str(">=3/10m").unwrap(); + assert_eq!(threshold.times, 3); + assert_eq!(threshold.duration, Duration::from_secs(600)); + } + + #[test] + fn source_location_rate_limiter_basic() { + let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 100); + + // First alert from location A should pass + assert!(limiter.evaluate("file_a.rs", "10", 1000)); + + // Second alert from same location within window should be rate limited + assert!(!limiter.evaluate("file_a.rs", "10", 1100)); + + // Alert from different location should pass (independent rate limiting) + assert!(limiter.evaluate("file_b.rs", "20", 1100)); + + // Same location after window passes should alert again + assert!(limiter.evaluate("file_a.rs", "10", 1700)); // 1000 + 600 + 100 + } + + #[test] + fn source_location_rate_limiter_different_lines_same_file() { + let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 100); + + // Different lines in same file should be independent + assert!(limiter.evaluate("file.rs", "10", 1000)); + assert!(limiter.evaluate("file.rs", "20", 1000)); + assert!(limiter.evaluate("file.rs", "30", 1000)); + + // Each should still be rate limited individually + assert!(!limiter.evaluate("file.rs", "10", 1100)); + assert!(!limiter.evaluate("file.rs", "20", 1100)); + } + + #[test] + fn source_location_rate_limiter_cleanup() { + // Use small max_entries to trigger cleanup + let mut limiter = SourceLocationRateLimiter::new(Duration::from_secs(600), 3); + + // Fill up the limiter + assert!(limiter.evaluate("file1.rs", "1", 1000)); + assert!(limiter.evaluate("file2.rs", "2", 1000)); + assert!(limiter.evaluate("file3.rs", "3", 1000)); + assert_eq!(limiter.last_timestamps.len(), 3); + + // Add one more, triggering cleanup - but all are fresh so none removed + assert!(limiter.evaluate("file4.rs", "4", 1000)); + // Still have 4 after cleanup since none are old enough + assert_eq!(limiter.last_timestamps.len(), 4); + + // Now add with a timestamp far in the future - old entries should be cleaned + assert!(limiter.evaluate("file5.rs", "5", 2000)); + // Should have cleaned up entries from timestamp 1000 (older than 600 sec window) + assert_eq!(limiter.last_timestamps.len(), 1); + } +} diff --git a/signal-gateway/src/http.rs b/signal-gateway/src/http.rs new file mode 100644 index 0000000..f23992c --- /dev/null +++ b/signal-gateway/src/http.rs @@ -0,0 +1,85 @@ +//! Schema for the alertmanager http POST requests that are sent to us + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use url::Url; + +pub type Timestamp = DateTime; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Resolved, + Firing, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AlertMessage { + // should be 4.0 + pub version: String, + pub group_key: String, + pub status: Status, + pub receiver: String, + #[serde(default)] + pub group_labels: BTreeMap, + #[serde(default)] + pub common_labels: BTreeMap, + #[serde(default)] + pub common_annotations: BTreeMap, + #[serde(alias = "externalURL")] + pub external_url: String, + pub alerts: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Alert { + pub status: Status, + #[serde(default)] + pub labels: BTreeMap, + #[serde(default)] + pub annotations: BTreeMap, + pub starts_at: Timestamp, + pub ends_at: Timestamp, + #[serde(alias = "generatorURL")] + pub generator_url: String, + pub fingerprint: String, +} + +impl Alert { + pub fn parse_expr_from_generator_url(&self) -> Result { + let url = Url::parse(&self.generator_url).map_err(|err| err.to_string())?; + + for (k, v) in url.query_pairs() { + if k == "g0.expr" { + return Ok(v.into_owned()); + } + } + + Err("Couldn't find g0.expr".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_alert_message_parsing() { + let text = r#"{"receiver":"notify-chris","status":"firing","alerts":[{"status":"firing","labels":{"alertname":"Low tick success rate","instance":"172.31.5.8:9000","job":"ec2"},"annotations":{"summary":"Low tick success rate"},"startsAt":"2025-11-07T04:21:46.17Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9090/graph?g0.expr=rate%28tick_successes%5B5m%5D%29+%3C+0.9\u0026g0.tab=1","fingerprint":"543b6a7a3042ae2c"},{"status":"firing","labels":{"alertname":"Long tail tick times","instance":"172.31.5.8:9000","job":"ec2","quantile":"0.99"},"annotations":{"summary":"Long tail tick times"},"startsAt":"2025-11-07T04:50:01.17Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9090/graph?g0.expr=tick_time%7Bquantile%3D%220.99%22%7D+%3E+0.8\u0026g0.tab=1","fingerprint":"97130d38ef0ff0a4"}],"groupLabels":{},"commonLabels":{"instance":"172.31.5.8:9000","job":"ec2"},"commonAnnotations":{},"externalURL":"http://ip-172-31-10-138.eu-west-3.compute.internal:9093","version":"4","groupKey":"{}:{}","truncatedAlerts":0}"#; + + let msg: AlertMessage = serde_json::from_str(text).unwrap(); + + assert_eq!(&msg.receiver, "notify-chris"); + assert_eq!(msg.alerts.len(), 2); + assert_eq!( + msg.alerts[0].annotations.get("summary").unwrap(), + "Low tick success rate" + ); + + let expr = msg.alerts[1].parse_expr_from_generator_url().unwrap(); + assert_eq!(expr, r#"tick_time{quantile="0.99"} > 0.8"#); + } +} diff --git a/signal-gateway/src/human_duration.rs b/signal-gateway/src/human_duration.rs new file mode 100644 index 0000000..1f070a4 --- /dev/null +++ b/signal-gateway/src/human_duration.rs @@ -0,0 +1,63 @@ +//! Display wrapper for formatting chrono::TimeDelta as "T-duration" + +use chrono::TimeDelta; +use std::{ + fmt::{self, Display}, + time::Duration, +}; + +/// A display wrapper that formats a chrono::TimeDelta as "T-duration" or "T+duration" +/// with coarse precision (truncated to minutes for durations > 1 hour). +#[derive(Clone, Copy, Debug)] +pub struct HumanTMinus(pub TimeDelta); + +impl Display for HumanTMinus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (prefix, abs_duration) = if self.0.num_seconds() >= 0 { + ("T-", self.0) + } else { + ("T+", -self.0) + }; + + let std_dur = abs_duration.to_std().unwrap_or_default(); + let mut secs = std_dur.as_secs(); + + // Reduce precision to minutes for durations > 1 hour + if secs > 3600 { + secs -= secs % 60; + } + + let coarse_dur = Duration::new(secs, 0); + // Remove spaces for compact format (e.g., "T-1m30s" not "T-1m 30s") + let formatted = humantime::format_duration(coarse_dur) + .to_string() + .replace(' ', ""); + write!(f, "{}{}", prefix, formatted) + } +} + +impl From for HumanTMinus { + fn from(td: TimeDelta) -> Self { + HumanTMinus(td) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_human_t_minus_positive() { + assert_eq!(HumanTMinus(TimeDelta::seconds(30)).to_string(), "T-30s"); + assert_eq!(HumanTMinus(TimeDelta::seconds(90)).to_string(), "T-1m30s"); + assert_eq!(HumanTMinus(TimeDelta::hours(1)).to_string(), "T-1h"); + // > 1 hour: truncate to minutes + assert_eq!(HumanTMinus(TimeDelta::seconds(3700)).to_string(), "T-1h1m"); + } + + #[test] + fn test_human_t_minus_negative() { + assert_eq!(HumanTMinus(TimeDelta::seconds(-30)).to_string(), "T+30s"); + assert_eq!(HumanTMinus(TimeDelta::seconds(-3700)).to_string(), "T+1h1m"); + } +} diff --git a/signal-gateway/src/init_logging.rs b/signal-gateway/src/init_logging.rs new file mode 100644 index 0000000..6af0976 --- /dev/null +++ b/signal-gateway/src/init_logging.rs @@ -0,0 +1,41 @@ +//! Logging initialization for signal-gateway + +use std::env; +use tracing::info; + +pub fn init_logging() { + // Install rustls crypto provider before any TLS connections are made. + // This is needed because we have both aws-lc-rs and ring in our dependency tree, + // and rustls 0.23 can't auto-detect which one to use when both are present. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + if env::var("RUST_LOG").is_err() { + unsafe { + env::set_var("RUST_LOG", "info"); + } + } + + // Build a default tracing subscriber, writing to STDERR + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_file(true) + .with_line_number(true) + .init(); + + // load dotenv file + match dotenvy::dotenv() { + Ok(path) => info!("Read dotenv file from: {}", path.display()), + Err(dotenvy::Error::Io(io_error)) => { + if matches!(io_error.kind(), std::io::ErrorKind::NotFound) { + info!("Couldn't find a dotenv file"); + } else { + panic!("Io error when reading dot env file: {io_error}") + } + } + Err(err) => { + panic!("Error reading dotenv file: {err}") + } + } +} diff --git a/signal-gateway/src/jsonrpc.rs b/signal-gateway/src/jsonrpc.rs new file mode 100644 index 0000000..3bcc853 --- /dev/null +++ b/signal-gateway/src/jsonrpc.rs @@ -0,0 +1,557 @@ +//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs +#![allow(clippy::too_many_arguments)] + +use jsonrpsee::async_client::ClientBuilder; +use jsonrpsee::core::client::SubscriptionClientT; +use jsonrpsee::proc_macros::rpc; +use serde::Deserialize; +use serde_json::Value; +use tokio::net::ToSocketAddrs; + +pub use jsonrpsee::core::ClientError as RpcClientError; + +#[rpc(client)] +pub trait Rpc { + #[method(name = "addDevice", param_kind = map)] + async fn add_device( + &self, + account: Option, + uri: String, + ) -> Result; + + #[method(name = "addStickerPack", param_kind = map)] + async fn add_sticker_pack( + &self, + account: Option, + uri: String, + ) -> Result; + + #[method(name = "block", param_kind = map)] + fn block( + &self, + account: Option, + recipients: Vec, + #[allow(non_snake_case)] groupIds: Vec, + ) -> Result; + + #[method(name = "deleteLocalAccountData", param_kind = map)] + fn delete_local_account_data( + &self, + account: Option, + #[allow(non_snake_case)] ignoreRegistered: Option, + ) -> Result; + + #[method(name = "getAttachment", param_kind = map)] + fn get_attachment( + &self, + account: Option, + id: String, + recipient: Option, + #[allow(non_snake_case)] groupId: Option, + ) -> Result; + + #[method(name = "getAvatar", param_kind = map)] + fn get_avatar( + &self, + account: Option, + contact: Option, + profile: Option, + #[allow(non_snake_case)] groupId: Option, + ) -> Result; + + #[method(name = "getSticker", param_kind = map)] + fn get_sticker( + &self, + account: Option, + #[allow(non_snake_case)] packId: String, + #[allow(non_snake_case)] stickerId: u32, + ) -> Result; + + #[method(name = "getUserStatus", param_kind = map)] + fn get_user_status( + &self, + account: Option, + recipients: Vec, + usernames: Vec, + ) -> Result; + + #[method(name = "joinGroup", param_kind = map)] + fn join_group(&self, account: Option, uri: String) -> Result; + + #[allow(non_snake_case)] + #[method(name = "finishChangeNumber", param_kind = map)] + fn finish_change_number( + &self, + account: Option, + number: String, + verificationCode: String, + pin: Option, + ) -> Result; + + #[method(name = "finishLink", param_kind = map)] + fn finish_link( + &self, + #[allow(non_snake_case)] deviceLinkUri: String, + #[allow(non_snake_case)] deviceName: String, + ) -> Result; + + #[method(name = "listAccounts", param_kind = map)] + fn list_accounts(&self) -> Result; + + #[method(name = "listContacts", param_kind = map)] + fn list_contacts( + &self, + account: Option, + recipients: Vec, + #[allow(non_snake_case)] allRecipients: bool, + blocked: Option, + name: Option, + ) -> Result; + + #[method(name = "listDevices", param_kind = map)] + fn list_devices(&self, account: Option) -> Result; + + #[method(name = "listGroups", param_kind = map)] + fn list_groups( + &self, + account: Option, + #[allow(non_snake_case)] groupIds: Vec, + ) -> Result; + + #[method(name = "listIdentities", param_kind = map)] + fn list_identities( + &self, + account: Option, + number: Option, + ) -> Result; + + #[method(name = "listStickerPacks", param_kind = map)] + fn list_sticker_packs(&self, account: Option) -> Result; + + #[method(name = "quitGroup", param_kind = map)] + fn quit_group( + &self, + account: Option, + #[allow(non_snake_case)] groupId: String, + delete: bool, + admins: Vec, + ) -> Result; + + #[method(name = "register", param_kind = map)] + fn register( + &self, + account: Option, + voice: bool, + captcha: Option, + ) -> Result; + + #[method(name = "removeContact", param_kind = map)] + fn remove_contact( + &self, + account: Option, + recipient: String, + forget: bool, + hide: bool, + ) -> Result; + + #[method(name = "removeDevice", param_kind = map)] + fn remove_device( + &self, + account: Option, + #[allow(non_snake_case)] deviceId: u32, + ) -> Result; + + #[method(name = "removePin", param_kind = map)] + fn remove_pin(&self, account: Option) -> Result; + + #[method(name = "remoteDelete", param_kind = map)] + fn remote_delete( + &self, + account: Option, + #[allow(non_snake_case)] targetTimestamp: u64, + recipients: Vec, + #[allow(non_snake_case)] groupIds: Vec, + #[allow(non_snake_case)] noteToSelf: bool, + ) -> Result; + + #[allow(non_snake_case)] + #[method(name = "send", param_kind = map)] + fn send( + &self, + account: Option, + recipients: Vec, + groupIds: Vec, + noteToSelf: bool, + endSession: bool, + message: String, + attachments: Vec, + viewOnce: bool, + mentions: Vec, + textStyle: Vec, + quoteTimestamp: Option, + quoteAuthor: Option, + quoteMessage: Option, + quoteMention: Vec, + quoteTextStyle: Vec, + quoteAttachment: Vec, + previewUrl: Option, + previewTitle: Option, + previewDescription: Option, + previewImage: Option, + sticker: Option, + storyTimestamp: Option, + storyAuthor: Option, + editTimestamp: Option, + ) -> Result; + + #[method(name = "sendContacts", param_kind = map)] + fn send_contacts(&self, account: Option) -> Result; + + #[method(name = "sendPaymentNotification", param_kind = map)] + fn send_payment_notification( + &self, + account: Option, + recipient: String, + receipt: String, + note: String, + ) -> Result; + + #[method(name = "sendReaction", param_kind = map)] + fn send_reaction( + &self, + account: Option, + recipients: Vec, + #[allow(non_snake_case)] groupIds: Vec, + #[allow(non_snake_case)] noteToSelf: bool, + emoji: String, + #[allow(non_snake_case)] targetAuthor: String, + #[allow(non_snake_case)] targetTimestamp: u64, + remove: bool, + story: bool, + ) -> Result; + + #[method(name = "sendReceipt", param_kind = map)] + fn send_receipt( + &self, + account: Option, + recipient: String, + #[allow(non_snake_case)] targetTimestamps: Vec, + r#type: String, + ) -> Result; + + #[method(name = "sendSyncRequest", param_kind = map)] + fn send_sync_request(&self, account: Option) -> Result; + + #[method(name = "sendTyping", param_kind = map)] + fn send_typing( + &self, + account: Option, + recipients: Vec, + #[allow(non_snake_case)] groupIds: Vec, + stop: bool, + ) -> Result; + + #[method(name = "sendMessageRequestResponse", param_kind = map)] + fn send_message_request_response( + &self, + account: Option, + recipients: Vec, + #[allow(non_snake_case)] groupIds: Vec, + r#type: String, + ) -> Result; + + #[method(name = "setPin", param_kind = map)] + fn set_pin(&self, account: Option, pin: String) -> Result; + + #[method(name = "submitRateLimitChallenge", param_kind = map)] + fn submit_rate_limit_challenge( + &self, + account: Option, + challenge: String, + captcha: String, + ) -> Result; + + #[method(name = "startChangeNumber", param_kind = map)] + fn start_change_number( + &self, + account: Option, + number: String, + voice: bool, + captcha: Option, + ) -> Result; + + #[method(name = "startLink", param_kind = map)] + fn start_link(&self, account: Option) -> Result; + + #[method(name = "trust", param_kind = map)] + fn trust( + &self, + account: Option, + recipient: String, + #[allow(non_snake_case)] trustAllKnownKeys: bool, + #[allow(non_snake_case)] verifiedSafetyNumber: Option, + ) -> Result; + + #[method(name = "unblock", param_kind = map)] + fn unblock( + &self, + account: Option, + recipients: Vec, + #[allow(non_snake_case)] groupIds: Vec, + ) -> Result; + + #[method(name = "unregister", param_kind = map)] + fn unregister( + &self, + account: Option, + #[allow(non_snake_case)] deleteAccount: bool, + ) -> Result; + + #[allow(non_snake_case)] + #[method(name = "updateAccount", param_kind = map)] + fn update_account( + &self, + account: Option, + deviceName: Option, + unrestrictedUnidentifiedSender: Option, + discoverableByNumber: Option, + numberSharing: Option, + ) -> Result; + + #[method(name = "updateConfiguration", param_kind = map)] + fn update_configuration( + &self, + account: Option, + #[allow(non_snake_case)] readReceipts: Option, + #[allow(non_snake_case)] unidentifiedDeliveryIndicators: Option, + #[allow(non_snake_case)] typingIndicators: Option, + #[allow(non_snake_case)] linkPreviews: Option, + ) -> Result; + + #[method(name = "updateContact", param_kind = map)] + fn update_contact( + &self, + account: Option, + recipient: String, + name: Option, + expiration: Option, + ) -> Result; + + #[method(name = "updateGroup", param_kind = map)] + fn update_group( + &self, + account: Option, + #[allow(non_snake_case)] groupId: Option, + name: Option, + description: Option, + avatar: Option, + member: Vec, + #[allow(non_snake_case)] removeMember: Vec, + admin: Vec, + #[allow(non_snake_case)] removeAdmin: Vec, + ban: Vec, + unban: Vec, + #[allow(non_snake_case)] resetLink: bool, + #[allow(non_snake_case)] link: Option, + #[allow(non_snake_case)] setPermissionAddMember: Option, + #[allow(non_snake_case)] setPermissionEditDetails: Option, + #[allow(non_snake_case)] setPermissionSendMessages: Option, + expiration: Option, + ) -> Result; + + #[method(name = "updateProfile", param_kind = map)] + fn update_profile( + &self, + account: Option, + #[allow(non_snake_case)] givenName: Option, + #[allow(non_snake_case)] familyName: Option, + about: Option, + #[allow(non_snake_case)] aboutEmoji: Option, + #[allow(non_snake_case)] mobileCoinAddress: Option, + avatar: Option, + #[allow(non_snake_case)] removeAvatar: bool, + ) -> Result; + + #[method(name = "uploadStickerPack", param_kind = map)] + fn upload_sticker_pack( + &self, + account: Option, + path: String, + ) -> Result; + + #[method(name = "verify", param_kind = map)] + fn verify( + &self, + account: Option, + #[allow(non_snake_case)] verificationCode: String, + pin: Option, + ) -> Result; + + #[subscription( + name = "subscribeReceive" => "receive", + unsubscribe = "unsubscribeReceive", + item = RecvMessage, + param_kind = map + )] + async fn subscribe_receive(&self, account: Option) -> SubscriptionResult; + + #[method(name = "version")] + fn version(&self) -> Result; +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JsonLink { + pub device_link_uri: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecvMessage { + pub envelope: Envelope, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Envelope { + pub source: String, + pub source_number: String, + pub source_uuid: String, + pub source_name: String, + pub source_device: i64, + pub timestamp: u64, + pub data_message: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataMessage { + pub timestamp: u64, + pub message: String, +} + +/// Connect to signal-cli over tcp socket +pub async fn connect_tcp( + tcp: impl ToSocketAddrs, +) -> Result { + let (sender, receiver) = super::transports::tcp::connect(tcp).await?; + + Ok(ClientBuilder::default().build_with_tokio(sender, receiver)) +} + +impl Envelope { + /// Send a read-receipt for an envelope + pub async fn send_read_receipt( + &self, + client: &impl RpcClient, + account: impl Into, + ) -> Result<(), RpcClientError> { + if let Some(dm) = self.data_message.as_ref() { + let _ = client + .send_receipt( + Some(account.into()), + self.source_uuid.clone(), + vec![dm.timestamp], + "read".into(), + ) + .await?; + } + Ok(()) + } +} + +/// Helper for invoking send, which has way too many parameters +pub struct SignalMessage { + pub sender: String, + pub recipient: Vec, + pub message: String, + pub attachments: Vec, +} + +impl SignalMessage { + #[allow(non_snake_case)] + pub async fn send(self, client: &impl RpcClient) -> Result<(), RpcClientError> { + // See note about string indexing here: https://github.com/AsamK/signal-cli/wiki/FAQ#string-indexing-units + let message_len_utf16: usize = self.message.chars().map(|c| c.len_utf16()).sum(); + /* + account: Option, + recipients: Vec, + groupIds: Vec, + noteToSelf: bool, + endSession: bool, + message: String, + attachments: Vec, + viewOnce: bool, + mentions: Vec, + textStyle: Vec, + quoteTimestamp: Option, + quoteAuthor: Option, + quoteMessage: Option, + quoteMention: Vec, + quoteTextStyle: Vec, + quoteAttachment: Vec, + previewUrl: Option, + previewTitle: Option, + previewDescription: Option, + previewImage: Option, + sticker: Option, + storyTimestamp: Option, + storyAuthor: Option, + editTimestamp: Option, + */ + let account = Some(self.sender); + let recipients = self.recipient; + let groupIds = vec![]; + let noteToSelf = false; + let endSession = false; + let message = self.message; + let attachments = self.attachments; + let viewOnce = false; + let mentions = vec![]; + let textStyle = vec![format!("0:{message_len_utf16}:MONOSPACE")]; + let quoteTimestamp = None; + let quoteAuthor = None; + let quoteMention = vec![]; + let quoteMessage = None; + let quoteTextStyle = vec![]; + let quoteAttachment = vec![]; + let previewUrl = None; + let previewTitle = None; + let previewDescription = None; + let previewImage = None; + let sticker = None; + let storyTimestamp = None; + let storyAuthor = None; + let editTimestamp = None; + + let _resp = client + .send( + account, + recipients, + groupIds, + noteToSelf, + endSession, + message, + attachments, + viewOnce, + mentions, + textStyle, + quoteTimestamp, + quoteAuthor, + quoteMessage, + quoteMention, + quoteTextStyle, + quoteAttachment, + previewUrl, + previewTitle, + previewDescription, + previewImage, + sticker, + storyTimestamp, + storyAuthor, + editTimestamp, + ) + .await?; + Ok(()) + } +} diff --git a/signal-gateway/src/main.rs b/signal-gateway/src/main.rs new file mode 100644 index 0000000..56ef26b --- /dev/null +++ b/signal-gateway/src/main.rs @@ -0,0 +1,147 @@ +use conf::Conf; +use hyper::service::service_fn; +use hyper_util::rt::TokioIo; +use hyper_util::server::conn::auto; +use std::{str::FromStr, sync::Arc, time::Duration}; +use syslog_rfc5424::SyslogMessage; +use tokio::net::{TcpListener, UdpSocket}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +pub mod config; +pub mod gateway; +pub mod http; +mod human_duration; +mod init_logging; +pub mod jsonrpc; +pub mod plotter; +pub mod transports; + +use config::Config; +use gateway::Gateway; + +#[tokio::main] +async fn main() { + init_logging::init_logging(); + + let config = Config::parse(); + + info!("Config = {config:#?}"); + + if config.dry_run { + return; + } + + let token = CancellationToken::new(); + + let gateway = Arc::new(Gateway::new(config.gateway, token.clone()).await); + + let listener = TcpListener::bind(config.http_listen_addr).await.unwrap(); + info!("Listening for http on {}", config.http_listen_addr); + + let udp_socket = UdpSocket::bind(config.udp_listen_addr).await.unwrap(); + info!("Listening for udp on {}", config.udp_listen_addr); + + // Listen for ctrl-c + let thread_token = token.clone(); + tokio::task::spawn(async move { + tokio::signal::ctrl_c().await.unwrap(); + warn!("ctrl-c: Stop requested"); + thread_token.cancel(); + }); + + // Start the two server tasks + let _http_task = start_http_task(listener, gateway.clone()); + let _udp_task = start_udp_task(udp_socket, gateway.clone()); + + // Run gateway task and block on it returning. Note that it exits if the token is canceled. + gateway.run().await; +} + +fn start_http_task(listener: TcpListener, gateway: Arc) -> tokio::task::JoinHandle<()> { + // Loop waiting for http incoming connections, and pass them to gateway + tokio::task::spawn(async move { + loop { + let Ok((stream, remote_addr)) = listener + .accept() + .await + .inspect_err(|err| error!("Error accepting connection: {err}")) + else { + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + }; + info!("New connection from: {}", remote_addr); + + // Spawn a new task to handle each connection + let thread_gateway = gateway.clone(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + + // Serve the connection using auto protocol detection (HTTP/1 or HTTP/2) + if let Err(err) = auto::Builder::new(hyper_util::rt::TokioExecutor::new()) + .serve_connection( + io, + service_fn(|req| { + let thread_gateway = thread_gateway.clone(); + async move { thread_gateway.handle_http_request(req).await } + }), + ) + .await + { + error!("Error serving connection: {err}"); + } + }); + } + }) +} + +fn start_udp_task(udp_socket: UdpSocket, gateway: Arc) -> tokio::task::JoinHandle<()> { + // Loop waiting for http incoming connections, and pass them to gateway + tokio::task::spawn(async move { + let mut buf = vec![0u8; 8192]; + loop { + let Ok((len, _addr)) = udp_socket + .recv_from(&mut buf) + .await + .inspect_err(|err| error!("Error receiving UDP packet: {err}")) + else { + continue; + }; + + let Ok(text) = str::from_utf8(&buf[0..len]) + .inspect_err(|err| error!("UDP packet was not utf8: {err}")) + else { + continue; + }; + + let Ok(msg) = SyslogMessage::from_str(text) + .inspect_err(|err| error!("UDP packet was not valid syslog: {err}:\n{text}")) + else { + continue; + }; + + gateway.handle_syslog_message(msg).await; + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_syslog_parsing() { + SyslogMessage::from_str( + "<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports", + ) + .unwrap(); + SyslogMessage::from_str( + "<12>1 2025-11-08T02:24:10.815221698+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports", + ) + .unwrap(); + SyslogMessage::from_str( + "<12>1 2025-11-08T02:24:10.815+00:00 ip-172-31-5-8 app 92748 - - Dropped 3/4 reports due to staleness" + ) + .unwrap(); + } +} diff --git a/signal-gateway/src/plotter.rs b/signal-gateway/src/plotter.rs new file mode 100644 index 0000000..d126932 --- /dev/null +++ b/signal-gateway/src/plotter.rs @@ -0,0 +1,255 @@ +use crate::http::Alert; +use chrono::Utc; +use conf::Conf; +use prom_client::{ + AlertInfo, AlertsRequest, ExtractLabels, Labels, LabelsRequest, MetricTimeseries, MetricValue, + PromRequest, QueryRangeRequest, QueryRequest, SeriesRequest, + plot::{PlotStyle, PlotThreshold}, +}; +use rand::RngCore; +use reqwest::Client as ReqwestClient; +use rust_decimal::Decimal; +use std::{error::Error, str::FromStr, time::Duration}; +use tracing::{info, warn}; +use walkdir::WalkDir; + +#[derive(Clone, Conf, Debug)] +pub struct PlotterConfig { + #[conf(long, env)] + pub prometheus_host: String, + #[conf(long, env, default_value = "30m", value_parser = conf_extra::parse_duration)] + pub plot_age_limit: Duration, +} + +pub struct Plotter { + config: PlotterConfig, + plot_dir: String, + reqwest_client: ReqwestClient, + skip_labels: Vec, +} + +impl Plotter { + pub fn new(config: PlotterConfig) -> Self { + let plot_dir = "/tmp".into(); + let reqwest_client = ReqwestClient::new(); + let skip_labels = vec!["job".into(), "instance".into()]; + + Self { + config, + plot_dir, + reqwest_client, + skip_labels, + } + } + + fn create_plot( + &self, + matrix: &[MetricTimeseries], + threshold: Option, + title: Option<&str>, + ) -> Result> { + let filename = format!( + "{dir}/plot-{num}.png", + dir = self.plot_dir, + num = rand::rng().next_u64() + ); + let mut plot_style = PlotStyle::default().dark_mode(); + plot_style.skip_labels = self.skip_labels.clone(); + if let Some(title) = title { + plot_style.title = Some(title.to_owned()); + } + + plot_style.plot_timeseries(&filename, matrix, threshold)?; + Ok(filename) + } + + pub fn purge_old_plots(&self) { + for entry in WalkDir::new(&self.plot_dir) + .min_depth(1) + .max_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let Some(ext) = entry.path().extension() else { + continue; + }; + if ext != "png" && ext != ".png" { + continue; + } + let path = entry.path().display(); + let Ok(metadata) = entry + .metadata() + .inspect_err(|err| warn!("Couldn't get metadata for {path}: {err}")) + else { + continue; + }; + let Ok(time) = metadata + .created() + .inspect_err(|err| warn!("Couldn't get creation time for {path}: {err}")) + else { + continue; + }; + let Ok(elapsed) = time + .elapsed() + .inspect_err(|err| warn!("Elapsed time calculation failed for {path}: {err}")) + else { + continue; + }; + if elapsed > self.config.plot_age_limit + && let Err(err) = std::fs::remove_file(entry.path()) + { + warn!("Couldn't remove old png file {path}: {err}"); + } + } + } + + pub async fn create_alert_plot(&self, alert: &Alert) -> Result> { + let expr = alert.parse_expr_from_generator_url()?; + + // Parse expressions like "query < 0.09" or "query < 0.09 and on (instance) up{...}" + // We look for comparison operators and extract the threshold + let (base_query, threshold) = parse_alert_expr(&expr)?; + + // Build label selector from alert labels (excluding job/instance) + let label_selector = build_label_selector(&alert.labels, &self.skip_labels); + let query = if label_selector.is_empty() { + base_query.to_owned() + } else { + format!("{base_query}{{{label_selector}}}") + }; + + let now = Utc::now(); + let elapsed = now - alert.starts_at; + // Extend elapsed by 210%, but use at least plot_age_limit (default 30m) + let lengthen = elapsed + .checked_mul(31) + .and_then(|e| e.checked_div(10)) + .ok_or("timedelta overflow")?; + let min_range = chrono::TimeDelta::from_std(self.config.plot_age_limit)?; + let range = lengthen.max(min_range); + + info!("Prom range query: {query}"); + let matrix = QueryRangeRequest::builder(query.clone()) + .range(now - range..now) + .build() + .send_with_client(&self.reqwest_client, &self.config.prometheus_host) + .await? + .into_matrix()?; + + self.create_plot(&matrix, Some(threshold), Some(&query)) + } + + pub async fn create_oneoff_plot( + &self, + query: String, + since: Duration, + ) -> Result> { + info!("Prom range query: {query}"); + let matrix = QueryRangeRequest::builder(query.to_owned()) + .since(since) + .build() + .send_with_client(&self.reqwest_client, &self.config.prometheus_host) + .await? + .into_matrix()?; + + self.create_plot(&matrix, None, Some(&query)) + } + + #[allow(clippy::type_complexity)] + pub async fn oneoff_query( + &self, + query: String, + ) -> Result<(ExtractLabels, Vec>), Box> { + info!("Prom query: {query}"); + let vector: Vec = QueryRequest { query, time: None } + .send_with_client(&self.reqwest_client, &self.config.prometheus_host) + .await? + .into_vector()?; + + let labels = ExtractLabels::new(vector.iter().map(|mv| &mv.metric), &self.skip_labels); + let values = vector.into_iter().map(|mv| mv.value).collect(); + + Ok((labels, values)) + } + + pub async fn series( + &self, + matches: impl IntoIterator>, + ) -> Result, Box> { + Ok(SeriesRequest { + matches: matches.into_iter().map(|s| s.as_ref().to_owned()).collect(), + } + .send_with_client(&self.reqwest_client, &self.config.prometheus_host) + .await?) + } + + pub async fn labels( + &self, + matches: impl IntoIterator>, + ) -> Result, Box> { + Ok(LabelsRequest { + matches: matches.into_iter().map(|s| s.as_ref().to_owned()).collect(), + } + .send_with_client::<()>(&self.reqwest_client, &self.config.prometheus_host) + .await?) + } + + pub async fn alerts(&self) -> Result, Box> { + Ok(AlertsRequest {} + .send_with_client(&self.reqwest_client, &self.config.prometheus_host) + .await? + .alerts) + } +} + +/// Build a prometheus label selector string from alert labels, excluding specified labels. +/// E.g., {"asset": "ETH", "job": "ec2"} with skip=["job"] -> `asset="ETH"` +fn build_label_selector( + labels: &std::collections::BTreeMap, + skip: &[String], +) -> String { + labels + .iter() + .filter(|(k, _)| !skip.contains(k) && *k != "alertname") + .map(|(k, v)| format!("{k}=\"{v}\"")) + .collect::>() + .join(",") +} + +/// Parse an alert expression to extract the base query and threshold. +/// +/// Handles expressions like: +/// - "query < 0.09" +/// - "query > 100" +/// - "rate(tick_successes[5m]) < 0.09 and on (instance) up{job=\"ec2\"}" +/// +/// Returns (base_query, threshold) where base_query is the part before the comparator. +fn parse_alert_expr(expr: &str) -> Result<(String, PlotThreshold), Box> { + // Look for comparison operators with surrounding spaces + for comparator in [" < ", " > "] { + if let Some(pos) = expr.find(comparator) { + let base_query = expr[..pos].to_owned(); + let after_comparator = &expr[pos + comparator.len()..]; + + // The threshold is the first space-delimited token after the comparator + let numeric = after_comparator + .split_whitespace() + .next() + .ok_or("no numeric after comparator")?; + + let limit = f64::from_str(numeric)?; + let threshold = if comparator == " < " { + PlotThreshold::LessThan(limit) + } else { + PlotThreshold::GreaterThan(limit) + }; + + return Ok((base_query, threshold)); + } + } + + Err("no comparator (< or >) found in expression".into()) +} diff --git a/signal-gateway/src/transports/mod.rs b/signal-gateway/src/transports/mod.rs new file mode 100644 index 0000000..24772a9 --- /dev/null +++ b/signal-gateway/src/transports/mod.rs @@ -0,0 +1,60 @@ +//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs + +use futures_util::{Sink, SinkExt, Stream, stream::StreamExt}; +use jsonrpsee::core::client::{ReceivedMessage, TransportReceiverT, TransportSenderT}; +use thiserror::Error; + +mod stream_codec; +pub mod tcp; + +#[derive(Debug, Error)] +enum Errors { + #[error("Other: {0}")] + Other(String), + #[error("Closed")] + Closed, +} + +struct Sender> { + inner: T, +} + +impl + Unpin + 'static> TransportSenderT + for Sender +{ + type Error = Errors; + + async fn send(&mut self, body: String) -> Result<(), Self::Error> { + self.inner + .send(body) + .await + .map_err(|e| Errors::Other(format!("{e:?}")))?; + Ok(()) + } + + async fn close(&mut self) -> Result<(), Self::Error> { + self.inner + .close() + .await + .map_err(|e| Errors::Other(format!("{e:?}")))?; + Ok(()) + } +} + +struct Receiver { + inner: T, +} + +impl> + Unpin + 'static> TransportReceiverT + for Receiver +{ + type Error = Errors; + + async fn receive(&mut self) -> Result { + match self.inner.next().await { + None => Err(Errors::Closed), + Some(Ok(msg)) => Ok(ReceivedMessage::Text(msg)), + Some(Err(e)) => Err(Errors::Other(format!("{e:?}"))), + } + } +} diff --git a/signal-gateway/src/transports/stream_codec.rs b/signal-gateway/src/transports/stream_codec.rs new file mode 100644 index 0000000..341fa52 --- /dev/null +++ b/signal-gateway/src/transports/stream_codec.rs @@ -0,0 +1,63 @@ +//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs + +use std::{io, str}; +use tokio_util::bytes::BytesMut; +use tokio_util::codec::{Decoder, Encoder}; + +type Separator = u8; + +/// Stream codec for streaming protocols (ipc, tcp) +#[derive(Debug, Default)] +pub struct StreamCodec { + incoming_separator: Separator, + outgoing_separator: Separator, +} + +impl StreamCodec { + /// Default codec with streaming input data. Input can be both enveloped and not. + pub fn stream_incoming() -> Self { + StreamCodec::new(b'\n', b'\n') + } + + /// New custom stream codec + pub fn new(incoming_separator: Separator, outgoing_separator: Separator) -> Self { + StreamCodec { + incoming_separator, + outgoing_separator, + } + } +} + +impl Decoder for StreamCodec { + type Item = String; + type Error = io::Error; + + fn decode(&mut self, buf: &mut BytesMut) -> io::Result> { + if let Some(i) = buf + .as_ref() + .iter() + .position(|&b| b == self.incoming_separator) + { + let line = buf.split_to(i); + let _ = buf.split_to(1); + + match str::from_utf8(line.as_ref()) { + Ok(s) => Ok(Some(s.to_string())), + Err(_) => Err(io::Error::other("invalid UTF-8")), + } + } else { + Ok(None) + } + } +} + +impl Encoder for StreamCodec { + type Error = io::Error; + + fn encode(&mut self, msg: String, buf: &mut BytesMut) -> io::Result<()> { + let mut payload = msg.into_bytes(); + payload.push(self.outgoing_separator); + buf.extend_from_slice(&payload); + Ok(()) + } +} diff --git a/signal-gateway/src/transports/tcp.rs b/signal-gateway/src/transports/tcp.rs new file mode 100644 index 0000000..ae80759 --- /dev/null +++ b/signal-gateway/src/transports/tcp.rs @@ -0,0 +1,22 @@ +use std::io::Error; + +use futures_util::stream::StreamExt; +use jsonrpsee::core::client::{TransportReceiverT, TransportSenderT}; +use tokio::net::{TcpStream, ToSocketAddrs}; +use tokio_util::codec::Decoder; + +use super::stream_codec::StreamCodec; +use super::{Receiver, Sender}; + +/// Connect to a JSON-RPC TCP server. +pub async fn connect( + socket: impl ToSocketAddrs, +) -> Result<(impl TransportSenderT + Send, impl TransportReceiverT + Send), Error> { + let connection = TcpStream::connect(socket).await?; + let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split(); + + let sender = Sender { inner: sink }; + let receiver = Receiver { inner: stream }; + + Ok((sender, receiver)) +}