From fd992d7e3c03f37fbcafe9d3f26c72a2ead3b2a7 Mon Sep 17 00:00:00 2001 From: Toby Vincent Date: Thu, 26 Sep 2024 17:31:16 -0500 Subject: feat!: impl full api --- Cargo.lock | 448 ++++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 10 ++ assets/index.css | 61 +++++++ assets/index.html | 30 ++++ assets/index.js | 87 ++++++++++ config.toml | 25 +-- index.html | 109 ------------ src/api.rs | 73 ++++++++ src/api/services.rs | 40 +++++ src/error.rs | 22 +++ src/lib.rs | 82 ++++----- src/main.rs | 93 ++++++---- src/service.rs | 203 ++++++++-------------- src/service/http.rs | 49 ++++++ src/service/systemd.rs | 33 ++++ src/service/tcp.rs | 28 ++++ 16 files changed, 1024 insertions(+), 369 deletions(-) create mode 100644 assets/index.css create mode 100644 assets/index.html create mode 100644 assets/index.js delete mode 100644 index.html create mode 100644 src/api.rs create mode 100644 src/api/services.rs create mode 100644 src/service/http.rs create mode 100644 src/service/systemd.rs create mode 100644 src/service/tcp.rs diff --git a/Cargo.lock b/Cargo.lock index da7d5be..db02f18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,26 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "async-trait" +version = "0.1.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -29,6 +49,83 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +[[package]] +name = "axum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f43644eed690f5374f1af436ecd6aea01cd201f6fbdf0178adaf6907afb2cec" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6b8ba012a258d63c9adfa28b9ddcf66149da6f986c5b5452e629d5ee64bf00" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.1", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -160,6 +257,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -176,12 +288,34 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -200,8 +334,10 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -292,12 +428,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ce4ef31cda248bbdb6e6820603b82dfcd9e833db65a43e997a0ccec777d11fe" + [[package]] name = "httparse" version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.4.1" @@ -311,6 +459,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -353,9 +502,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da62f120a8a37763efb0cf8fdf264b884c7b8b9ac8660b900c8661030c00e6ba" +checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" dependencies = [ "bytes", "futures-channel", @@ -366,7 +515,6 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", - "tower", "tower-service", "tracing", ] @@ -412,6 +560,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.158" @@ -436,6 +590,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "155db5e86c6e45ee456bf32fad5a290ee1f7151c2faca27ea27097568da67d1a" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.4" @@ -448,6 +617,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -486,6 +665,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "object" version = "0.36.4" @@ -546,30 +735,16 @@ dependencies = [ ] [[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pin-project" -version = "1.1.5" +name = "overload" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" -dependencies = [ - "pin-project-internal", -] +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] -name = "pin-project-internal" -version = "1.1.5" +name = "percent-encoding" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" @@ -607,6 +782,50 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + [[package]] name = "reqwest" version = "0.12.7" @@ -639,7 +858,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.1", "system-configuration", "tokio", "tokio-native-tls", @@ -725,6 +944,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.18" @@ -795,6 +1020,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.7" @@ -816,6 +1051,15 @@ dependencies = [ "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" @@ -857,11 +1101,21 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" name = "statsrv" version = "0.1.0" dependencies = [ + "axum", + "axum-extra", + "futures", + "futures-util", + "hyper-util", "main_error", "reqwest", "serde", + "serde_json", "thiserror", + "tokio", "toml", + "tower-http", + "tracing", + "tracing-subscriber", ] [[package]] @@ -881,6 +1135,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.1" @@ -944,6 +1204,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -971,9 +1241,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -1044,17 +1326,43 @@ dependencies = [ [[package]] name = "tower" -version = "0.4.13" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" dependencies = [ "futures-core", "futures-util", - "pin-project", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1075,10 +1383,23 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.32" @@ -1086,6 +1407,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" 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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1094,6 +1445,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -1132,12 +1492,24 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1230,6 +1602,28 @@ dependencies = [ "wasm-bindgen", ] +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-registry" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 0a558a2..69f7eeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,18 @@ version = "0.1.0" edition = "2021" [dependencies] +axum = "0.7.6" +axum-extra = "0.9.4" +futures = "0.3.30" +futures-util = "0.3.30" +hyper-util = { version = "0.1.9", features = ["tokio"] } main_error = "0.1.2" reqwest = { version = "0.12.7", features = ["blocking"] } serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" thiserror = "1.0.63" +tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "net"] } toml = "0.8.19" +tower-http = { version = "0.6.1", features = ["fs", "trace"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } diff --git a/assets/index.css b/assets/index.css new file mode 100644 index 0000000..9c170e4 --- /dev/null +++ b/assets/index.css @@ -0,0 +1,61 @@ +html { + background: var(--bg); + color: var(--fg); + font-size: 12pt; + font-family: monospace; + display: flex; + flex-direction: column; +} +body { + height: calc(100vh - 1rem); + width: calc(100vw - 4rem); + max-width: 640px; + margin: 0 2rem 1rem; + line-height: 1.4; + align-self: center; +} +main { + display: flex; + flex-direction: column; +} +hgroup { + text-align: center; +} +table { + width: 100%; + max-width: 500px; + align-self: center; + border-collapse: collapse; +} +tr { + border: 2px solid #ddd; +} +td { + padding: 5px; +} +td:first-of-type + td { + text-align: right; +} +.ok { + color: #228b22; +} +.warning { + color: #ff8c00; +} +.error { + color: #dc143c; +} + +#status { + color: #fff; + background: #228b22; +} +#status.ok { + background: #228b22; +} +#status.warning { + background: #ff8c00; +} +#status.error { + background: #dc143c; +} diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..c168318 --- /dev/null +++ b/assets/index.html @@ -0,0 +1,30 @@ + + + + + + status.tobyvin.dev + + + + +
+
+
+

tobyvin.dev Status

+
+
+ + + + + + +
StatusNo issues detected
+ +
+ +
+
+ + diff --git a/assets/index.js b/assets/index.js new file mode 100644 index 0000000..173ed72 --- /dev/null +++ b/assets/index.js @@ -0,0 +1,87 @@ +/** + * @typedef {Object} Check + * @property {String} status - 'pass'|'fail'|'warn' + * @property {String} output - Details. Not present if 'pass' + */ + +/** + * @typedef {Check} HealthCheck + * @property {Map} checks + */ + +async function getHealthCheck() { + const url = "api/healthcheck"; + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + + const json = await response.json(); + return json; + } catch (error) { + console.error(error.message); + } +} + +function updateStatus(check) { + const statusElm = document.getElementById("status"); + const issuesElm = document.getElementById("issues"); + switch (check.status) { + case "pass": + issuesElm.textContent = "No issues detected"; + statusElm.setAttribute("class", "ok"); + break; + case "fail": + issuesElm.textContent = `${check.output} issues detected`; + statusElm.setAttribute("class", "error"); + break; + case "warn": + issuesElm.textContent = `${check.output} warnings detected`; + statusElm.setAttribute("class", "warning"); + break; + default: + issuesElm.textContent = "Unknown"; + statusElm.setAttribute("class", "warning"); + } +} + +getHealthCheck().then((healthCheck) => { + const table = document.getElementById("services"); + const evtSource = new EventSource("sse"); + updateStatus(healthCheck); + + for (const [service, check] of Object.entries(healthCheck.checks)) { + const row = table.insertRow(); + + const nameNode = row.insertCell(); + nameNode.textContent = service; + + const stateNode = row.insertCell(); + switch (check.status) { + case "pass": + stateNode.textContent = "Operational"; + stateNode.setAttribute("class", "ok"); + break; + case "fail": + stateNode.textContent = "Down"; + stateNode.title = check.output; + stateNode.setAttribute("class", "error"); + break; + case "warn": + stateNode.textContent = "Warning"; + stateNode.title = check.output; + stateNode.setAttribute("class", "warning"); + break; + default: + stateNode.textContent = "Unknown"; + statusElm.setAttribute("class", "warning"); + } + + evtSource.addEventListener(service, (event) => { + const status = JSON.parse(event.data); + stateNode.textContent = status.state; + stateNode.title = status.output; + }); + } +}); diff --git a/config.toml b/config.toml index 867ee91..815ae94 100644 --- a/config.toml +++ b/config.toml @@ -1,20 +1,7 @@ -title = "tobyvin.dev" -template_path = "./index.html" -output_dir = "/tmp/out" +root = "assets" +address = "127.0.0.1:8080" -[[services]] -name = "tobyvin.dev" -type = "http" -url = "https://tobyvin.dev" -method = "GET" -status = 200 - -[[services]] -name = "ollama" -type = "systemd" -service = "ollama.service" - -[[services]] -name = "DNS" -type = "tcp" -address = "10.42.0.1:53" +[services] +"tobyvin.dev" = { url = "https://tobyvin.dev" } +ollama = { service = "ollama.service" } +dns = { address = "10.42.0.1:53" } diff --git a/index.html b/index.html deleted file mode 100644 index 0b0254b..0000000 --- a/index.html +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - status.tobyvin.dev - - - - -
-
-
-

{title} Status

-
-
- -
-
Status
-
{down} issue(s) detected
-
- -
- {{services}} -
{name}
-
{state}
- {{end}} -
-
- - diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..e6a91ba --- /dev/null +++ b/src/api.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use axum::{extract::State, response::IntoResponse, Json}; +use serde::{Deserialize, Serialize}; + +use crate::{service::Services, Check, Error, Status}; + +pub mod services; + +pub fn router() -> axum::Router { + use axum::routing::get; + + axum::Router::new() + .route("/healthcheck", get(healthcheck)) + .merge(services::router()) + .fallback(fallback) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Health { + pub status: Status, + pub output: Option, + pub checks: HashMap, +} + +impl From for Health { + fn from(value: T) -> Self { + Health { + status: Status::Fail, + output: Some(value.to_string()), + ..Default::default() + } + } +} + +impl IntoResponse for Health { + fn into_response(self) -> axum::response::Response { + Json(self).into_response() + } +} + +pub async fn healthcheck(State(services): State) -> Health { + let checks = match services.check().await { + Ok(c) => c, + Err(err) => { + return Health { + status: Status::Fail, + output: Some(err.to_string()), + ..Default::default() + } + } + }; + + let (status, output) = match checks + .values() + .filter(|s| !matches!(s.status, Status::Pass)) + .count() + { + 0 => (Status::Pass, None), + 1 => (Status::Fail, Some("1 issue detected".to_string())), + n => (Status::Fail, Some(format!("{n} issues detected"))), + }; + + Health { + status, + output, + checks, + } +} + +pub async fn fallback(uri: axum::http::Uri) -> Error { + Error::RouteNotFound(uri) +} diff --git a/src/api/services.rs b/src/api/services.rs new file mode 100644 index 0000000..59e891f --- /dev/null +++ b/src/api/services.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use axum::{ + extract::{Path, Query, State}, + Json, Router, +}; +use axum_extra::routing::Resource; +use serde::{Deserialize, Serialize}; + +use crate::{service::Services, Check, Error, Status}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServiceQuery { + pub name: Option, + pub state: Option, +} + +pub fn router() -> Router { + Resource::named("services").index(index).show(show).into() +} + +pub async fn index( + Query(query): Query, + State(services): State, +) -> Result>, Error> { + services + .check_filtered(|name| (!query.name.as_ref().is_some_and(|s| s != name))) + .await + .map(Json) +} + +pub async fn show( + Path(name): Path, + State(services): State, +) -> Result { + services + .check_one(&name) + .await + .ok_or_else(|| Error::ServiceNotFound(name))? +} diff --git a/src/error.rs b/src/error.rs index ef30b97..109c944 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,4 +13,26 @@ pub enum Error { #[error("Invalid HTTP method")] Method, + + #[error("Axum error: {0}")] + Axum(#[from] axum::Error), + + #[error("Route not found: {0}")] + RouteNotFound(axum::http::Uri), + + #[error("Service not found: {0}")] + ServiceNotFound(String), +} + +impl axum::response::IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + use axum::http::StatusCode; + + let status = match self { + Self::RouteNotFound(_) | Self::ServiceNotFound(_) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + (status, self.to_string()).into_response() + } } diff --git a/src/lib.rs b/src/lib.rs index 3437dca..2c9fa91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,61 +1,39 @@ -use std::ops::Range; +use std::path::PathBuf; -pub use crate::{ - error::{Error, Result}, - service::{Service, Status}, -}; +use serde::{Deserialize, Serialize}; +use service::Services; +use tower_http::services::ServeDir; +pub use crate::error::{Error, Result}; + +pub mod api; pub mod error; pub mod service; -pub fn generate(title: String, mut services: Vec, template: String) -> String { - let client = reqwest::blocking::Client::new(); - - let [up, down, unknown] = std::thread::scope(|s| { - let mut handles = Vec::new(); - for service in services.iter_mut() { - handles.push(s.spawn(|| service.check(client.clone()))); - } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + #[default] + Pass, + Fail, + Warn, +} - handles - .into_iter() - .map(|h| h.join().expect("Joining thread")) - .fold([0, 0, 0], |[up, down, unknown], res| match res { - Ok(true) => [up + 1, down, unknown], - Ok(false) => [up, down + 1, unknown], - Err(_) => [up, down, unknown + 1], - }) - }); +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Check { + pub status: Status, + pub output: Option, +} - template - .match_indices("{{services}}") - .zip(template.match_indices("{{end}}")) - .map(|(start, stop)| { - ( - start.0 + start.1.len()..stop.0, - start.0..stop.0 + stop.1.len(), - ) - }) - .collect::>() - .into_iter() - .fold(template, |mut template, (Range { start, end }, outer)| { - let replace_with = services - .iter() - .map(|service| { - template[start..end] - .replace("{name}", &service.name) - .replace("{title}", &service.kind.to_string()) - .replace("{state}", &service.state.to_string()) - .replace("{level}", &service.state.as_level()) - }) - .collect::(); +impl axum::response::IntoResponse for Check { + fn into_response(self) -> axum::response::Response { + axum::Json(self).into_response() + } +} - template.replace_range(outer, &replace_with); - template - }) - .replace("{title}", &title) - .replace("{status}", if down > 0 { "error" } else { "ok" }) - .replace("{up}", &up.to_string()) - .replace("{down}", &down.to_string()) - .replace("{unknown}", &unknown.to_string()) +pub fn router(root: PathBuf) -> axum::Router { + axum::Router::new() + .nest_service("/", ServeDir::new(root)) + .nest("/api", api::router()) + .layer(tower_http::trace::TraceLayer::new_for_http()) } diff --git a/src/main.rs b/src/main.rs index 2ff9fd3..97ed111 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,42 +1,69 @@ -use std::{fs::File, io::Write, path::PathBuf}; +use std::{fs::File, path::PathBuf}; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; -use statsrv::Service; +use statsrv::service::Services; + +#[cfg(not(debug_assertions))] +const DEFAULT_CONFIG: &str = "/etc/statsrv.toml"; +#[cfg(debug_assertions)] +const DEFAULT_CONFIG: &str = "./config.toml"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::registry() + .with( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let config = match Config::parse() { + Ok(c) => c, + Err(err) => { + tracing::debug!("Failed to read config file: `{err}`"); + tracing::debug!("Using default config values"); + Default::default() + } + }; + + let router = statsrv::router(config.root).with_state(config.services); + + let listener = tokio::net::TcpListener::bind(config.address).await.unwrap(); + tracing::info!("listening on {}", listener.local_addr().unwrap()); + + axum::serve(listener, router).await.map_err(Into::into) +} #[derive(Debug, Clone, serde::Deserialize)] +#[serde(default)] pub struct Config { - pub title: String, - pub template_path: PathBuf, - pub output_dir: Option, - pub address: Option, - pub services: Vec, + pub root: PathBuf, + pub address: String, + pub services: Services, } -fn main() -> Result<(), main_error::MainError> { - let mut args = std::env::args().skip(1); - - let config_path = args - .next() - .unwrap_or_else(|| "/etc/statsrv.toml".to_string()); - let config_file = File::open(config_path)?; - let config_toml = std::io::read_to_string(config_file)?; - let Config { - title, - template_path: template, - output_dir, - address: _, - services, - } = toml::from_str(&config_toml)?; - - let template_file = File::open(template)?; - let template = std::io::read_to_string(template_file)?; - let status_page = statsrv::generate(title, services, template); - - if let Some(output_dir) = output_dir { - std::fs::create_dir_all(&output_dir)?; - let mut html_writer = File::create(output_dir.join("index.html"))?; - - html_writer.write_all(status_page.as_bytes())?; +impl Config { + fn parse() -> Result> { + let config_path = std::env::args().nth(1).unwrap_or_else(|| { + tracing::debug!("Falling back to default config location"); + DEFAULT_CONFIG.to_string() + }); + + let config_file = File::open(&config_path)?; + let config_toml = std::io::read_to_string(config_file)?; + toml::from_str(&config_toml).map_err(Into::into) } +} - Ok(()) +impl Default for Config { + fn default() -> Self { + Self { + root: PathBuf::from("./"), + address: String::from("127.0.0.1:8080"), + services: Services::new(Default::default()), + } + } } diff --git a/src/service.rs b/src/service.rs index c5eb0d7..677db17 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,160 +1,105 @@ -use std::{fmt::Display, process::Command}; +use std::{collections::HashMap, fmt::Display}; -use reqwest::blocking::Client; +use futures::{stream::FuturesOrdered, TryStreamExt}; +use http::Http; use serde::Deserialize; +use systemd::Systemd; +use tcp::Tcp; -use crate::Error; +use crate::{Check, Error}; + +pub mod http; +pub mod systemd; +pub mod tcp; #[derive(Debug, Clone, Deserialize)] -pub struct Service { - pub name: String, +pub struct Services { #[serde(flatten)] - pub kind: Kind, - #[serde(skip)] - pub state: State, + inner: HashMap, + #[serde(skip, default = "Services::default_client")] + client: reqwest::Client, } -impl Service { - pub fn check(&mut self, client: Client) -> Result { - self.state = self.kind.get_state(client)?; - Ok(self.state.is_operational()) +impl Services { + pub fn new(services: HashMap) -> Self { + let client = reqwest::Client::new(); + Self { + inner: services, + client, + } } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum Kind { - Tcp { - address: String, - }, - Http { - url: String, - #[serde(default = "Kind::default_method")] - method: String, - #[serde(default = "Kind::default_code")] - status_code: u16, - }, - Systemd { - service: String, - }, -} -impl Kind { - fn default_method() -> String { - "GET".to_string() + fn default_client() -> reqwest::Client { + reqwest::Client::new() } - fn default_code() -> u16 { - 200 + pub async fn check(&self) -> Result, Error> { + let checks = self + .inner + .values() + .map(|service| service.check(self.client.clone())) + .collect::>() + .try_collect::>() + .await?; + + Ok(self + .inner + .keys() + .cloned() + .zip(checks) + .collect::>()) } - pub fn get_state(&self, client: Client) -> Result { - let state = match self { - Kind::Tcp { address } => { - if std::net::TcpStream::connect(address).is_ok() { - State::Operational - } else { - State::Down("Unreachable".to_string()) - } - } - Kind::Http { - method, - url, - status_code, - } => { - match client - .request(method.parse().map_err(|_| Error::Method)?, url) - .send()? - .status() - { - s if s.as_u16() == *status_code => State::Operational, - s => State::Down(s.to_string()), - } - } - Kind::Systemd { service } => { - let output = Command::new("systemctl") - .arg("is-active") - .arg(service) - .output()?; - - if output.status.success() { - State::Operational - } else { - State::Down(String::from_utf8_lossy(&output.stdout).to_string()) - } - } - }; - - Ok(state) + pub async fn check_one(&self, name: &str) -> Option> { + Some(self.inner.get(name)?.check(self.client.clone()).await) } -} -impl Display for Kind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Kind::Tcp { address } => write!(f, "tcp://{address}"), - Kind::Http { method, url, .. } => write!(f, "{method} {url}"), - Kind::Systemd { service } => write!(f, "{service}"), - } + pub async fn check_filtered

(&self, mut predicate: P) -> Result, Error> + where + P: FnMut(&String) -> bool, + { + let checks = self + .inner + .iter() + .filter_map(|(s, service)| predicate(s).then_some(service)) + .map(|service| service.check(self.client.clone())) + .collect::>() + .try_collect::>() + .await?; + + Ok(self + .inner + .keys() + .cloned() + .zip(checks) + .collect::>()) } } -#[derive(Debug, Clone, Default)] -pub struct Status { - pub info: String, - pub state: State, -} - -#[derive(Debug, Clone, Default)] -pub enum State { - #[default] - Unknown, - Operational, - Down(String), +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Service { + Http(Http), + Tcp(Tcp), + Systemd(Systemd), } -impl State { - /// Returns `true` if this is a `Unknown` variant. - pub fn is_unknown(&self) -> bool { - matches!(self, Self::Unknown) - } - - /// Returns `true` if this is a `Operational` variant. - pub fn is_operational(&self) -> bool { - matches!(self, Self::Operational) - } - - /// Returns `true` if this is a `Down` variant. - pub fn is_down(&self) -> bool { - matches!(self, Self::Down(_)) - } - - /// Converts the `State` into an `Option` containing `String` description if the `State` was - /// `Down` and `None` otherwise. - pub fn down_value(self) -> Option { - match self { - State::Unknown => None, - State::Operational => None, - State::Down(s) => Some(s), - } - } - - pub fn as_level(&self) -> String { +impl Service { + pub async fn check(&self, client: reqwest::Client) -> Result { match self { - State::Unknown => "warning", - State::Operational => "ok", - State::Down(_) => "error", + Service::Http(http) => http.check(client).await, + Service::Tcp(tcp) => tcp.check().await, + Service::Systemd(systemd) => systemd.check().await, } - .to_string() } } -impl Display for State { +impl Display for Service { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - State::Unknown => write!(f, "Unknown"), - State::Operational => write!(f, "Operational"), - State::Down(s) => write!(f, "{s}"), + Service::Http(http) => http.fmt(f), + Service::Tcp(tcp) => tcp.fmt(f), + Service::Systemd(systemd) => systemd.fmt(f), } } } diff --git a/src/service/http.rs b/src/service/http.rs new file mode 100644 index 0000000..15696a1 --- /dev/null +++ b/src/service/http.rs @@ -0,0 +1,49 @@ +use std::fmt::Display; + +use serde::Deserialize; + +use crate::{Check, Error, Status}; + +#[derive(Debug, Clone, Deserialize)] +pub struct Http { + pub url: String, + #[serde(default = "Http::default_method")] + pub method: String, + #[serde(default = "Http::default_code")] + pub status_code: u16, +} + +impl Http { + fn default_method() -> String { + "GET".to_string() + } + + fn default_code() -> u16 { + 200 + } +} + +impl Display for Http { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.method, self.url) + } +} + +impl Http { + pub async fn check(&self, client: reqwest::Client) -> Result { + let status_code = client + .request(self.method.parse().map_err(|_| Error::Method)?, &self.url) + .send() + .await? + .status() + .as_u16(); + + match status_code == self.status_code { + true => Ok(Check::default()), + false => Ok(Check { + status: Status::Fail, + output: Some(format!("Status code: {status_code}")), + }), + } + } +} diff --git a/src/service/systemd.rs b/src/service/systemd.rs new file mode 100644 index 0000000..2e3b74c --- /dev/null +++ b/src/service/systemd.rs @@ -0,0 +1,33 @@ +use std::{fmt::Display, process::Command}; + +use serde::Deserialize; + +use crate::{Check, Error, Status}; + +#[derive(Debug, Clone, Deserialize)] +pub struct Systemd { + pub service: String, +} + +impl Display for Systemd { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.service", self.service.trim_end_matches(".service")) + } +} + +impl Systemd { + pub async fn check(&self) -> Result { + let output = Command::new("systemctl") + .arg("is-active") + .arg(&self.service) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok((!output.status.success()) + .then(|| Check { + status: Status::Fail, + output: Some(format!("Service state: {}", stdout.trim())), + }) + .unwrap_or_default()) + } +} diff --git a/src/service/tcp.rs b/src/service/tcp.rs new file mode 100644 index 0000000..5f55091 --- /dev/null +++ b/src/service/tcp.rs @@ -0,0 +1,28 @@ +use std::fmt::Display; + +use serde::Deserialize; + +use crate::{Check, Error, Status}; + +#[derive(Debug, Clone, Deserialize)] +pub struct Tcp { + pub address: String, +} + +impl Display for Tcp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "tcp://{}", self.address) + } +} + +impl Tcp { + pub async fn check(&self) -> Result { + Ok(std::net::TcpStream::connect(&self.address) + .err() + .map(|err| Check { + status: Status::Fail, + output: Some(format!("error: {err}")), + }) + .unwrap_or_default()) + } +} -- cgit v1.2.3-70-g09d2