diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d6ba57e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: CI/CD + +on: + push: + branches: + - '**' + tags: + - 'v*' + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: cargo test + + build: + needs: test + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + - run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu + if: matrix.target == 'aarch64-unknown-linux-gnu' + - run: cargo build --release --target ${{ matrix.target }} + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + - run: tar -czf rsntp-${{ github.ref_name }}-${{ matrix.target }}.tar.gz -C target/${{ matrix.target }}/release rsntp + - uses: actions/upload-artifact@v4 + with: + name: rsntp-${{ matrix.target }} + path: rsntp-${{ github.ref_name }}-${{ matrix.target }}.tar.gz + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + - target: aarch64-unknown-linux-gnu + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/download-artifact@v4 + - run: | + gh release create ${{ github.ref_name }} \ + rsntp-x86_64-unknown-linux-gnu/rsntp-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz \ + rsntp-aarch64-unknown-linux-gnu/rsntp-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz \ + --generate-notes + env: + GH_TOKEN: ${{ github.token }} diff --git a/Cargo.lock b/Cargo.lock index 18d850d..8705688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,21 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "autocfg" @@ -14,12 +29,33 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "0.1.10" @@ -32,6 +68,78 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "generator" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" +dependencies = [ + "cc", + "cfg-if 1.0.0", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + [[package]] name = "getopts" version = "0.2.21" @@ -49,15 +157,99 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if 1.0.0", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "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.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if 1.0.0", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[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 = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + [[package]] name = "memoffset" version = "0.7.1" @@ -67,6 +259,25 @@ dependencies = [ "autocfg", ] +[[package]] +name = "moka" +version = "0.12.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "loom", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "thiserror", + "uuid", +] + [[package]] name = "net2" version = "0.2.39" @@ -84,19 +295,85 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cfg-if 1.0.0", "libc", "memoffset", "pin-utils", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[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 = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[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 = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -113,6 +390,53 @@ dependencies = [ "nix", ] +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus-client" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504ee9ff529add891127c4827eb481bd69dc0ebc72e9a682e187db4caa60c3ca" +dependencies = [ + "dtoa", + "itoa", + "parking_lot", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +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 = "rand" version = "0.8.5" @@ -140,32 +464,387 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "rsntp" -version = "0.0.1" +version = "0.1.1" dependencies = [ "byteorder", "getopts", + "moka", "net2", "privdrop", + "prometheus-client", "rand", + "regex", + "rustc_version_runtime", + "sysinfo", + "tiny_http", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[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 = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if 1.0.0", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows 0.52.0", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + [[package]] name = "unicode-width" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "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 = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if 1.0.0", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -187,3 +866,206 @@ 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" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +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", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] diff --git a/Cargo.toml b/Cargo.toml index ed22862..3f94fa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rsntp" -version = "0.0.1" +version = "0.1.1" authors = ["Miroslav Lichvar "] description = "Multi-threaded NTP server" license = "GPLv2+" @@ -8,6 +8,14 @@ license = "GPLv2+" [dependencies] byteorder = "1.5.0" getopts = "0.2.21" +moka = { version = "0.12", features = ["sync"] } net2 = "0.2.39" privdrop = "0.5.4" +prometheus-client = "0.22" rand = "0.8" +rustc_version_runtime = "0.3" +sysinfo = "0.30" +tiny_http = "0.12" + +[dev-dependencies] +regex = "1.0" diff --git a/Makefile b/Makefile index 3858668..755f4fd 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,11 @@ -debug: +debug: test cargo build -release: +release: test cargo build --release clean: cargo clean + +test: + cargo test diff --git a/README.adoc b/README.adoc index b198805..32c4300 100644 --- a/README.adoc +++ b/README.adoc @@ -38,3 +38,129 @@ response rate is jumping up and down as the request rate is changing. With both implementations 4 threads/instances are needed on this system to fully saturate the 1Gb/s link without any packets dropped, but +rsntp+ with 3 threads is very close to it. + +== Metrics support + ++rsntp+ provides optional metrics collection and HTTP endpoints for monitoring +server performance and client activity. When enabled with the ++--metrics-address+ option, the following HTTP endpoint becomes available: + ++/metrics+ - Prometheus-compatible metrics in text format + +With the addition of the +--client-cache+ option, the following HTTP endpoint +becomes available: + ++/clients+ - List of unique client IP addresses with request counts in CSV +format (this is a rudimentary equivalent to +ntpd -nc mrulist+ or +chronyc -n +clients+ for rsntp) + +Metrics and client cache are both disabled by default; there are no defaults for +their values. The client cache periodically must be an integer number of +seconds and the metrics address must be an address:port pair. + +The metrics include packet event counters, timing information, packet size +histograms, and process resource usage statistics. Metrics must be enabled for +client cache to be enabled. If client cache is enabled, the metrics also +include unique client counts by time period. + +Example to start rsntp with metrics and client cache support with a maximum of +128K clients for a 1 minute cache, and 1024K clients for a 1 hour cache: + +---- +rsntp --ipv6-address [2001:db8:1:2::7b]:123 --metrics-address [::1]:12345 --client-cache 128,60 --client-cache 1024,3600 & +---- + +Note that enabling client cache can be extremely memory intensive, and you +should choose appropriate limits and periods for your load. The effect of +enabling metrics on +rsntp+'s performance has not been measured on high load +systems and is left as an exercise for the user. + +Retrieving metrics: + +---- +$ curl -s [::1]:12345/metrics +# HELP rsntp_first_seen_time First time each packet event was seen (Unix nanoseconds). +# TYPE rsntp_first_seen_time gauge +rsntp_first_seen_time{thread_id="0",packet_event="client_receive_failed"} 1754149059888966149 +rsntp_first_seen_time{thread_id="0",packet_event="client_request_sent"} 1754095641157550773 +rsntp_first_seen_time{thread_id="0",packet_event="client_response_received"} 1754095641157705319 +rsntp_first_seen_time{thread_id="2",packet_event="server_request_received"} 1754095641206039321 +rsntp_first_seen_time{thread_id="2",packet_event="server_receive_failed"} 1754099103475955536 +rsntp_first_seen_time{thread_id="2",packet_event="server_response_sent"} 1754095641206117286 +rsntp_first_seen_time{thread_id="2",packet_event="server_unsupported_version"} 1754099103475938676 +# HELP rsntp_last_seen_time Last time each packet event was seen (Unix nanoseconds). +# TYPE rsntp_last_seen_time gauge +rsntp_last_seen_time{thread_id="0",packet_event="client_response_received"} 1754199817786192483 +rsntp_last_seen_time{thread_id="2",packet_event="server_unsupported_version"} 1754187197337029246 +rsntp_last_seen_time{thread_id="2",packet_event="server_request_received"} 1754199818529319613 +rsntp_last_seen_time{thread_id="0",packet_event="client_request_sent"} 1754199817786061650 +rsntp_last_seen_time{thread_id="2",packet_event="server_response_sent"} 1754199818529387914 +rsntp_last_seen_time{thread_id="2",packet_event="server_receive_failed"} 1754187197337063231 +rsntp_last_seen_time{thread_id="0",packet_event="client_receive_failed"} 1754157087664801849 +# HELP rsntp_packets NTP packet event counters. +# TYPE rsntp_packets counter +rsntp_packets_total{thread_id="0",packet_event="client_response_received"} 104100 +rsntp_packets_total{thread_id="2",packet_event="server_receive_failed"} 4 +rsntp_packets_total{thread_id="0",packet_event="client_request_sent"} 104107 +rsntp_packets_total{thread_id="2",packet_event="server_unsupported_version"} 4 +rsntp_packets_total{thread_id="2",packet_event="server_request_received"} 2498429 +rsntp_packets_total{thread_id="2",packet_event="server_response_sent"} 2498429 +rsntp_packets_total{thread_id="0",packet_event="client_receive_failed"} 7 +# HELP rsntp_packet_size_bytes NTP packet size in bytes. +# TYPE rsntp_packet_size_bytes histogram +rsntp_packet_size_bytes_sum 119924922.0 +rsntp_packet_size_bytes_count 2498433 +rsntp_packet_size_bytes_bucket{le="47.0"} 0 +rsntp_packet_size_bytes_bucket{le="55.0"} 2498429 +rsntp_packet_size_bytes_bucket{le="127.0"} 2498433 +rsntp_packet_size_bytes_bucket{le="+Inf"} 2498433 +# HELP rsntp_unique_clients Number of unique client IP addresses by time period. +# TYPE rsntp_unique_clients gauge +rsntp_unique_clients{period="86400"} 503582 +rsntp_unique_clients{period="3600"} 41606 +rsntp_unique_clients{period="60"} 1404 +# HELP rsntp_process_fds Process file descriptors by type. +# TYPE rsntp_process_fds gauge +rsntp_process_fds{fd_type="open"} 154 +rsntp_process_fds{fd_type="max"} 524288 +# HELP rsntp_process_memory_bytes Process memory usage in bytes by type. +# TYPE rsntp_process_memory_bytes gauge +rsntp_process_memory_bytes{memory_type="peak"} 1039429632 +rsntp_process_memory_bytes{memory_type="pin"} 0 +rsntp_process_memory_bytes{memory_type="rss"} 211402752 +rsntp_process_memory_bytes{memory_type="size"} 967446528 +rsntp_process_memory_bytes{memory_type="swap"} 606208 +rsntp_process_memory_bytes{memory_type="data"} 231411712 +rsntp_process_memory_bytes{memory_type="lib"} 1654784 +rsntp_process_memory_bytes{memory_type="pte"} 552960 +rsntp_process_memory_bytes{memory_type="hwm"} 211566592 +rsntp_process_memory_bytes{memory_type="lck"} 0 +rsntp_process_memory_bytes{memory_type="stk"} 135168 +rsntp_process_memory_bytes{memory_type="exe"} 1069056 +# HELP rsntp_process_runtime_seconds Process runtime in seconds with version info. +# TYPE rsntp_process_runtime_seconds gauge +rsntp_process_runtime_seconds{rsntp_version="0.1.0",rust_version="1.88.0"} 104177 +# HELP rsntp_process_threads Number of OS threads in the process. +# TYPE rsntp_process_threads gauge +rsntp_process_threads 12 +# EOF +---- + +Retrieving clients: + +---- +$ curl -s [::1]:12345/clients?period=60 +2001:db8:107:6d00:24ca:2884:d339:9eec,1 +2001:db8:1902:9600:4cd4:1ccd:f180:45c5,1 +2001:db8:4201:4c00:e73c:1545:aa58:94e1,2 +2001:db8:4205:8800:9e0b:1321:5667:9159,2 +2001:db8:4306:dd00:170:fb66:fb09:bc33,1 +2001:db8:5b03:a500:4354:5b43:9c1e:1740,1 +2001:db8:5c05:b200:52e6:716f:9518:a553,5 +2001:db8:5c07:1900:119d:bfcf:a31b:e245,1 +2001:db8:5d02:ef00:c3cc:3c1b:b9ba:f591,1 +2001:db8:6203:a000:8dc5:aac6:5097:420c,3 +2001:db8:6208:be00:2f67:3b89:bd9f:4a9,2 +2001:db8:630b:f900:62b7:e90:5962:af90,1 +... +---- diff --git a/plans/prd-packet-counters-prometheus.md b/plans/prd-packet-counters-prometheus.md new file mode 100644 index 0000000..9717044 --- /dev/null +++ b/plans/prd-packet-counters-prometheus.md @@ -0,0 +1,129 @@ +# PRD: Prometheus Packet Counters for NTP Server + +## Introduction/Overview + +This feature adds comprehensive packet monitoring and metrics collection to the RSNTP server using Prometheus metrics. The goal is to provide operational visibility into NTP server performance and packet handling for automated monitoring systems and operations teams performing debugging tasks. + +## Goals + +1. **Operational Visibility**: Provide detailed metrics on packet processing events +2. **Performance Monitoring**: Track packet counts, sizes, and client diversity +3. **Debugging Support**: Enable operations teams to identify and troubleshoot issues +4. **Zero Performance Impact**: Ensure no performance degradation when metrics are disabled +5. **Thread Safety**: Support concurrent metric updates from multiple server threads + +## User Stories + +- **As a monitoring system**, I want to collect NTP server metrics via `/metrics` endpoint so that I can track server health and performance +- **As an operations engineer**, I want to see packet event counters so that I can identify communication issues +- **As a system administrator**, I want to monitor unique client counts so that I can understand server load patterns +- **As a developer**, I want optional metrics collection so that production performance is not impacted when monitoring is disabled + +## Functional Requirements + +### 1. Command Line Interface +1.1. Add `--metrics-port` parameter to specify HTTP metrics endpoint port +1.2. Add `--client-cache-limits` parameter to configure unique client cache sizes (default: 64K,1M,16M for minute,hour,day) +1.3. When `--metrics-port` is omitted, metrics collection must be completely disabled +1.4. When enabled, metrics must be exposed on `/metrics` endpoint following Prometheus conventions + +### 2. Packet Event Counters +2.1. Implement `rsntp_packet_count` counter with the following packet events: +- `client_invalid_response` - Client received an invalid response +- `client_receive_failed` - Client failed to receive response +- `client_response_received` - Client received a response +- `client_send_failed` - Client send request failed +- `client_request_sent` - Client sent a request +- `server_packet_too_short` - NTP request packet too short (< 48 bytes) +- `server_receive_failed` - NTP request socket receive operation failed +- `server_request_received` - NTP request packet received +- `server_send_failed` - NTP response packet send operation failed +- `server_response_sent` - NTP response packet sent +- `server_unsupported_version` - NTP request for unsupported version + +2.2. Each counter must include labels: +- `thread_id`: Thread identifier (0 for global counters) +- `packet_event`: Event type from list above + +### 3. Timing Metrics +3.1. Implement `rsntp_first_seen_time` gauge (Unix nanoseconds) for each packet event +3.2. Implement `rsntp_last_seen_time` gauge (Unix nanoseconds) for each packet event +3.3. Both metrics must use same labels as `rsntp_packet_count` + +### 4. Packet Size Monitoring +4.1. Implement `rsntp_packet_size_bytes` histogram for all received packets +4.2. Use histogram buckets: <48, 48-56, 56-128, 128+ bytes +4.3. Record packet size before rejecting packets that are too short +4.4. Use global counters only (no per-thread breakdown needed) + +### 5. Unique Client Tracking +5.1. Implement `rsntp_unique_clients` gauge with labels: +- `period`: "minute", "hour", or "day" +- `ip_version`: "4" or "6" +5.2. Track unique IP addresses using TTL caches with configurable sizes: +- Minute cache: 64K entries (default) +- Hour cache: 1M entries (default) +- Day cache: 16M entries (default) +5.3. Cache sizes configurable via `--client-cache-limits` parameter +5.4. Count IPv4 and IPv6 addresses separately +5.5. Update gauge after each new client address is added + +### 6. Thread Safety and Performance +6.1. All metrics must be thread-safe for concurrent write access +6.2. Per-thread metric updates must also increment corresponding global metric (thread_id=0) +6.3. Zero performance impact when metrics collection is disabled + +### 7. Architecture Requirements +7.1. Encapsulate all metrics functionality in dedicated class/module +7.2. Prometheus implementation must be transparent to main program +7.3. Minimize code footprint in main.rs +7.4. HTTP metrics endpoint must run in separate lower-priority thread + +## Non-Goals (Out of Scope) + +- Real-time alerting functionality +- Metric data persistence beyond Prometheus scraping +- Custom metric aggregation beyond standard Prometheus types +- Integration with other monitoring systems besides Prometheus +- Metric authentication or access control + +## Technical Considerations + +### Dependencies +- Add `prometheus_client` crate for metrics collection +- Add `moka` crate for TTL caches +- Add `hyper` crate for lightweight HTTP server (commonly used, simple API, small footprint) + +### Implementation Structure +- Create dedicated metrics module with three enums: + - Counter events enum + - Gauge events enum + - Histogram events enum +- Expose functions for: + - Incrementing counters (with thread_id parameter) + - Updating gauges (with thread_id parameter) + - Recording histogram values (with thread_id parameter) + - Adding client IP addresses (with automatic gauge updates) + +### Thread Management +- Start HTTP server thread when metrics class is instantiated +- Use lower thread priority for metrics HTTP server +- Ensure metrics writing operations remain time-critical +- Ignore all errors in metrics recording operations + +## Success Metrics + +- **Functional**: All packet events are accurately counted and exposed via `/metrics` +- **Performance**: Zero measurable performance impact when metrics disabled +- **Reliability**: No metric data loss under concurrent access +- **Usability**: Operations teams can successfully monitor NTP server health +- **Integration**: Monitoring systems can successfully scrape metrics endpoint + +## Implementation Details + +### Resolved Requirements +- **Histogram Buckets**: Use buckets <48, 48-56, 56-128, 128+ bytes for packet size +- **Cache Size Limits**: Default 64K/1M/16M for minute/hour/day, configurable via `--client-cache-limits` +- **HTTP Server**: Use `hyper` crate for lightweight HTTP server with simple API +- **Error Handling**: Ignore all errors in metrics recording operations +- **Metric Naming**: All metrics prefixed with `rsntp_` diff --git a/plans/responses-prd-prometheus.md b/plans/responses-prd-prometheus.md new file mode 100644 index 0000000..7783297 --- /dev/null +++ b/plans/responses-prd-prometheus.md @@ -0,0 +1,55 @@ +Create a PRD for adding packet counters using prometheus_client to @main.rs + +1. Primary goal is to provide operational visibility + +2. Target is automated monitoring systems, and operations teams doing debugging + +3. The primary metric to be tracked should be called packet_count. The following packet events should be counted: + - Client received an invalid response + - Client failed to receive response + - Client received a response + - Client send request failed + - Client sent a request + - NTP request received packet too short (< 48 bytes) + - NTP requests socket receive operation failed + - NTP request packet received + - NTP response packet send operation failed + - NTP response packet sent + - NTP request received for an unsupported NTP version + +4. Prometheus integration: + - Metrics should be exposed on /metrics as per prometheus conventions + - The HTTP port should be specified via the command line parameter "--metrics-port". If the parameter is omitted, metrics collection should be disabled. + - Thread id should be included as a label on packet counters; a thread id of zero should be used to indicate global counters. + - The packet events list above must be provided as a label named "packet_event". Create a relatively short but descriptive string for each of the above event types for use as the label value. + +5. All metrics must be thread-safe, since they will be written by multiple threads at once. + +6. Metrics collection is optional. There should be no performance impact when metrics are disabled. + +7. In addition to packet_count, the following metrics should be created for each packet event, with the same labels as packet_count: + - first_seen_time (gauge, unix time as an integer number of nanoseconds) + - last_seen_time (gauge, unix time as an integer number of nanoseconds) + + Additionally, the following global metrics should be recorded (no need to record thread-level metrics for these): + - packet_size_bytes (histogram, integer) - this should be recorded before packets are rejected for being too short + - unique_clients (gauge, integer count of clients) - the total number of unique IP addresses seen in the past 1 minute, in the past 1 hour, and in the past 1 day. Use a label named "period" with the value of "day", "hour", or "minute", and a label indicating the IP version (4 or 6). IPv4 and IPv6 should be counted separately; do not use a combined counter for both. + +8. Implementation guidelines: + - The count of unique clients should be determined by adding every client's address to each of three TTL caches using the moka crate, with the expiry time set for each different interval (minute, hour, day). + - All metrics functionality must be encapsulated in a dedicated class; the prometheus implementation must be transparent to the main program, and the code footprint in main.rs should be as small as possible. + - Within the dedicated class, every metric update on a particular thread must also increment the corresponding metric for thread id zero (the global metric). + - The dedicated class should expose functions for: + - incrementing counters, using an enum with a value for each packet event listed above + - updating other metric types (gauge, histogram), also using an enum for each expected usage of that metric type (so there should be three enums: one for valid counters, one for valid gauges, and one for valid histograms) + - adding a client IP address to the pool of seen addresses; internally, after adding the address to the pool, it should update the unique_clients gauge + - Each of the above functions should accept the calling thread id as a parameter. Zero is a valid thread id, indicating a global metric. + - The HTTP endpoint must run in a separate thread with lower priority than the NTP server threads. Writing metrics is time-critical; reading metrics is not. This thread should be started when the metrics class is instantiated. + +# Open questions + +1. Histogram Buckets: < 48, 48 < 56, 56 < 128, 128+. +2. Cache Size Limit: 64K for minute, 1M for hour, 16M for day, but these should be configurable via the command line parameter "--client-cache-limits". +3. HTTP Server: use whatever your existing knowledge shows is commonly used, prioritise a simple API and small code footprint +4. Error Handling: errors in recording metrics should be ignored +5. Metric Naming: metrics should have the prefix "rsntp_" diff --git a/plans/tasks-prd-packet-counters-prometheus.md b/plans/tasks-prd-packet-counters-prometheus.md new file mode 100644 index 0000000..ed6910d --- /dev/null +++ b/plans/tasks-prd-packet-counters-prometheus.md @@ -0,0 +1,57 @@ +# Tasks: Prometheus Packet Counters for NTP Server + +## Relevant Files + +- `plans/prd-packet-counters-prometheus.md` - PRD containing high level description and implementation guidelines +- `src/metrics/mod.rs` - Main metrics module with MetricsCollector struct and all Prometheus functionality +- `src/metrics/events.rs` - Enums for PacketEvent, GaugeEvent, and HistogramEvent with string conversion +- `src/metrics/client_cache.rs` - TTL cache implementation for unique client tracking with configurable limits +- `src/metrics/http_server.rs` - HTTP server for `/metrics` endpoint using hyper (placeholder) +- `src/main.rs` - Command line argument parsing and metrics initialization (to be updated) +- `Cargo.toml` - Dependencies for prometheus_client, moka, and hyper crates +- `tests/metrics_test.rs` - Integration tests for metrics functionality (to be created) +- `tests/client_cache_test.rs` - Unit tests for client cache functionality (to be created) + +### Notes + +- Use `cargo test` to run all tests +- Use `cargo test metrics` to run metrics-specific tests +- Metrics should have zero performance impact when disabled + +## Tasks + +- [x] 1. Set up Dependencies and Project Structure + - [x] 1.1 Add prometheus_client, moka, and hyper dependencies to Cargo.toml + - [x] 1.2 Create src/metrics/ directory structure + - [x] 1.3 Create src/metrics/mod.rs with public module declarations + - [x] 1.4 Create placeholder files for events.rs, client_cache.rs, and http_server.rs + +- [x] 2. Implement Core Metrics Module + - [x] 2.1 Define PacketEvent, GaugeEvent, and HistogramEvent enums in events.rs + - [x] 2.2 Implement MetricsCollector struct with Prometheus registry and metrics + - [x] 2.3 Add thread-safe counter increment functions with thread_id parameter + - [x] 2.4 Add gauge update functions for first_seen_time and last_seen_time + - [x] 2.5 Add histogram recording function for packet sizes + - [x] 2.6 Implement TTL cache for unique client tracking in client_cache.rs + - [x] 2.7 Add client IP tracking function with automatic gauge updates + - [x] 2.8 Ensure all metric operations ignore errors and have zero impact when disabled + +- [x] 3. Add Command Line Interface Support + - [x] 3.1 Add --metrics-port parameter to clap configuration in main.rs + - [x] 3.2 Add --client-cache-limits parameter with default "64K,1M,16M" + - [x] 3.3 Parse client cache limits into separate values for minute/hour/day + - [x] 3.4 Pass metrics configuration to MetricsCollector constructor + +- [x] 4. Implement HTTP Metrics Endpoint + - [x] 4.1 Create HTTP server using std::net in http_server.rs + - [x] 4.2 Implement /metrics endpoint that returns Prometheus format + - [x] 4.3 Start HTTP server in separate lower-priority thread + - [x] 4.4 Handle server startup and shutdown gracefully + +- [x] 5. Integrate Metrics into Main Application + - [x] 5.1 Initialize MetricsCollector in main.rs when --metrics-port is provided + - [x] 5.2 Add metric recording calls to NTP server packet handling code + - [x] 5.3 Record packet events for all server operations (receive, send, errors) + - [x] 5.4 Record client IP addresses for unique client tracking + - [x] 5.5 Record packet sizes for histogram metrics + - [x] 5.6 Ensure metrics calls are conditional and have no impact when disabled diff --git a/src/main.rs b/src/main.rs index 6893913..4c329f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,9 +15,14 @@ extern crate byteorder; extern crate getopts; +extern crate moka; extern crate net2; -extern crate rand; extern crate privdrop; +extern crate prometheus_client; +extern crate rand; +extern crate rustc_version_runtime; +extern crate sysinfo; +extern crate tiny_http; use std::thread; use std::env; @@ -36,6 +41,9 @@ use net2::unix::UnixUdpBuilderExt; use rand::random; +mod metrics; +use metrics::events::PacketEvent; + #[derive(Debug, Copy, Clone)] struct NtpTimestamp { ts: u64, @@ -122,14 +130,22 @@ struct NtpPacket { } impl NtpPacket { - fn receive(socket: &UdpSocket) -> io::Result { + fn receive(socket: &UdpSocket, metrics: &Option>, thread_id: u32) -> io::Result { let mut buf = [0; 1024]; let (len, addr) = socket.recv_from(&mut buf)?; + if let Some(ref m) = metrics { + m.record_packet_size(len); + m.inc_client(addr.ip()); + } + let local_ts = NtpTimestamp::now(); if len < 48 { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ServerPacketTooShort, thread_id); + } return Err(Error::new(ErrorKind::UnexpectedEof, "Packet too short")); } @@ -138,9 +154,16 @@ impl NtpPacket { let mode = buf[0] & 0x7; if version < 1 || version > 4 { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ServerUnsupportedVersion, thread_id); + } return Err(Error::new(ErrorKind::Other, "Unsupported version")); } + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ServerRequestReceived, thread_id); + } + Ok(NtpPacket{ remote_addr: addr, local_ts: local_ts, @@ -262,10 +285,11 @@ struct NtpServer { sockets: Vec, server_addr: String, debug: bool, + metrics: Option>, } impl NtpServer { - fn new(local_addrs: Vec, server_addr: String, debug: bool) -> NtpServer { + fn new(local_addrs: Vec, server_addr: String, debug: bool, metrics: Option>) -> NtpServer { let state = NtpServerState{ leap: 0, stratum: 0, @@ -304,10 +328,11 @@ impl NtpServer { sockets: sockets, server_addr: server_addr, debug: debug, + metrics: metrics, } } - fn process_requests(thread_id: u32, debug: bool, socket: UdpSocket, state: Arc>) { + fn process_requests(thread_id: u32, debug: bool, socket: UdpSocket, state: Arc>, metrics: Option>) { let mut last_update = NtpTimestamp::now(); let mut cached_state: NtpServerState; cached_state = *state.lock().unwrap(); @@ -315,7 +340,7 @@ impl NtpServer { println!("Server thread #{} started", thread_id); loop { - match NtpPacket::receive(&socket) { + match NtpPacket::receive(&socket, &metrics, thread_id) { Ok(request) => { if debug { println!("Thread #{} received {:?}", thread_id, request); @@ -333,25 +358,35 @@ impl NtpServer { Some(response) => { match response.send(&socket) { Ok(_) => { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ServerResponseSent, thread_id); + } if debug { println!("Thread #{} sent {:?}", thread_id, response); } }, - Err(e) => println!("Thread #{} failed to send packet to {}: {}", - thread_id, response.remote_addr, e) + Err(e) => { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ServerSendFailed, thread_id); + } + println!("Thread #{} failed to send packet to {}: {}", thread_id, response.remote_addr, e); + } } }, - None => {} + _none => {} } }, Err(e) => { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ServerReceiveFailed, thread_id); + } println!("Thread #{} failed to receive packet: {}", thread_id, e); }, } } } - fn update_state(state: Arc>, addr: SocketAddr, debug: bool) { + fn update_state(state: Arc>, addr: SocketAddr, debug: bool, metrics: Option>) { let request = NtpPacket::new_request(addr); let mut new_state: Option = None; let socket = match addr { @@ -363,24 +398,36 @@ impl NtpServer { match request.send(&socket) { Ok(_) => { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ClientRequestSent, 0); + } if debug { println!("Client sent {:?}", request); } }, Err(e) => { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ClientSendFailed, 0); + } println!("Client failed to send packet: {}", e); return; } } loop { - let response = match NtpPacket::receive(&socket) { + let response = match NtpPacket::receive(&socket, &None, 0) { Ok(packet) => { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ClientResponseReceived, 0); + } if debug { println!("Client received {:?}", packet); } if !packet.is_valid_response(&request) { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ClientInvalidResponse, 0); + } println!("Client received unexpected {:?}", packet); continue; } @@ -388,6 +435,9 @@ impl NtpServer { packet }, Err(e) => { + if let Some(ref m) = metrics { + m.update_packet_counter(PacketEvent::ClientReceiveFailed, 0); + } if debug { println!("Client failed to receive packet: {}", e); } @@ -419,11 +469,12 @@ impl NtpServer { let debug = self.debug; let cloned_socket = socket.try_clone().unwrap(); - threads.push(thread::spawn(move || {NtpServer::process_requests(id, debug, cloned_socket, state); })); + let metrics_clone = self.metrics.clone(); + threads.push(thread::spawn(move || {NtpServer::process_requests(id, debug, cloned_socket, state, metrics_clone); })); } while ! quit { - NtpServer::update_state(self.state.clone(), self.server_addr.parse().unwrap(), self.debug); + NtpServer::update_state(self.state.clone(), self.server_addr.parse().unwrap(), self.debug, self.metrics.clone()); thread::sleep(Duration::new(1, 0)); } @@ -434,6 +485,47 @@ impl NtpServer { } } +fn parse_client_cache_option(cache_str: &str) -> Option<(u64, u64)> { + let parts: Vec<&str> = cache_str.split(',').collect(); + if parts.len() != 2 { + eprintln!("Invalid client cache format. Expected: limit,ttl (e.g., 64,60)"); + return None; + } + + let limit = match parts[0].trim().parse::() { + Ok(val) => val * 1024, // Convert Kb to bytes + Err(_) => { + eprintln!("Invalid limit '{}' - must be a number of Kb", parts[0].trim()); + return None; + } + }; + + let ttl = match parts[1].trim().parse::() { + Ok(val) => val, + Err(_) => { + eprintln!("Invalid TTL '{}' - must be a number of seconds", parts[1].trim()); + return None; + } + }; + + Some((limit, ttl)) +} + +fn initialize_metrics(metrics_address: Option, client_cache_configs: &[(u64, u64)]) -> Option> { + if let Some(address) = metrics_address { + let collector = Arc::new(metrics::MetricsCollector::new(client_cache_configs)); + let server = metrics::MetricsServer::new(collector.clone(), address); + thread::spawn(move || { + if let Err(e) = server.start() { + eprintln!("Metrics server error: {}", e); + } + }); + Some(collector) + } else { + None + } +} + fn print_usage(opts: Options) { let brief = format!("Usage: rsntp [OPTIONS]"); print!("{}", opts.usage(&brief)); @@ -451,6 +543,8 @@ fn main() { opts.optopt("s", "server-address", "set server address (127.0.0.1:11123)", "ADDR:PORT"); opts.optopt("u", "user", "run as USER", "USER"); opts.optopt("r", "root", "change root directory", "DIR"); + opts.optopt("m", "metrics-address", "enable metrics endpoint on ADDR:PORT; default: metrics disabled", "ADDR:PORT"); + opts.optmulti("c", "client-cache", "set client cache limit,ttl in Kb,seconds (e.g., 64,60) - multiple allowed, default: client cache disabled", "LIMIT,TTL"); opts.optflag("d", "debug", "Enable debug messages"); opts.optflag("h", "help", "Print this help message"); @@ -473,6 +567,11 @@ fn main() { let n6 = matches.opt_str("6").unwrap_or("1".to_string()).parse().unwrap_or(1); let local_address4 = matches.opt_str("a").unwrap_or("0.0.0.0:123".to_string()); let local_address6 = matches.opt_str("b").unwrap_or("[::]:123".to_string()); + let metrics_address = matches.opt_str("metrics-address"); + let client_cache_configs: Vec<(u64, u64)> = matches.opt_strs("client-cache") + .iter() + .filter_map(|s| parse_client_cache_option(s)) + .collect(); for _ in 0..n4 { addrs.push(local_address4.clone()); @@ -482,7 +581,8 @@ fn main() { addrs.push(local_address6.clone()); } - let server = NtpServer::new(addrs, server_addr, matches.opt_present("d")); + let metrics = initialize_metrics(metrics_address, &client_cache_configs); + let server = NtpServer::new(addrs, server_addr, matches.opt_present("d"), metrics); if matches.opts_present(&["r".to_string(), "u".to_string()]) { privdrop::PrivDrop::default() diff --git a/src/metrics/client_cache.rs b/src/metrics/client_cache.rs new file mode 100644 index 0000000..1c55fc5 --- /dev/null +++ b/src/metrics/client_cache.rs @@ -0,0 +1,272 @@ +use moka::sync::Cache; +use std::collections::HashSet; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone)] +pub struct ClientCache { + caches: Vec>>, + ttls: Vec, +} + +impl ClientCache { + pub fn new(configs: &[(u64, u64)]) -> Self { + // Sort caches by descending TTL so that we can use the keys in the first one + // as the definitive list of clients. + let mut sorted_configs = configs.to_vec(); + sorted_configs.sort_by(|a, b| b.1.cmp(&a.1)); + + let (caches, ttls): (Vec<_>, Vec<_>) = sorted_configs + .iter() + .filter(|&&(limit, _)| limit > 0) + .map(|&(limit, ttl)| (Self::create_cache(limit, ttl), ttl)) + .unzip(); + + Self { caches, ttls } + } + + fn create_cache(limit: u64, ttl: u64) -> Arc> { + Arc::new( + Cache::builder() + .max_capacity(limit) + .time_to_live(Duration::from_secs(ttl)) + .build(), + ) + } + + fn get_client(&self, ip: IpAddr) -> Vec { + self.caches + .iter() + .map(|cache| cache.get(&ip).unwrap_or(0)) + .collect() + } + + #[allow(dead_code)] + pub fn get_clients(&self) -> Vec<(IpAddr, u64)> { + self.caches + .iter() + .flat_map(|cache| cache.iter().map(|(ip, count)| (*ip, count))) + .collect() + } + + pub fn get_clients_with_counters(&self, period: Option) -> Vec<(IpAddr, Vec)> { + if self.caches.is_empty() { + return Vec::new(); + } + + match period { + Some(ttl) => { + // Find cache with matching TTL + if let Some(cache_index) = self.ttls.iter().position(|&t| t == ttl) { + self.caches[cache_index] + .iter() + .map(|(ip, count)| (*ip, vec![count])) + .collect() + } + else { + Vec::new() + } + } + None => { + // Get unique IPs from the first cache (longest TTL) + let unique_ips: HashSet = self.caches[0] + .iter() + .map(|(ip, _)| *ip) + .collect(); + + unique_ips + .into_iter() + .map(|ip| (ip, self.get_client(ip))) + .collect() + } + } + } + + #[allow(dead_code)] + fn get_counts(&self) -> Vec { + self.caches + .iter() + .map(|cache| cache.entry_count()) + .collect() + } + + #[allow(dead_code)] + fn get_ttls(&self) -> &[u64] { + &self.ttls + } + + fn inc_cache(cache: &Arc>, ip: IpAddr) -> u64 { + let count = cache.get(&ip).unwrap_or(0) + 1; + cache.insert(ip, count); + count + } + + pub fn inc_client(&self, ip: IpAddr) -> Vec { + self.caches + .iter() + .map(|cache| Self::inc_cache(cache, ip)) + .collect() + } + + pub fn iter_counts_ttls(&self) -> impl Iterator { + self.caches.iter().map(|cache| cache.entry_count()).zip(self.ttls.iter()) + } + + #[allow(dead_code)] + fn run_pending_tasks(&self) { + self.caches + .iter() + .for_each(|cache| cache.run_pending_tasks()); + } + +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_inc_and_get_client() { + let cache = ClientCache::new(&[(10, 60)]); + let ip = IpAddr::from_str("192.0.2.1").unwrap(); + + // Increment the client counter 10 times and make sure it returns the + // same thing when retrieved. + for i in 1..=10 { + let result = cache.inc_client(ip); + assert_eq!(result.len(), 1); + let result = cache.get_client(ip); + assert_eq!(result[0], i); + } + + // Get the list of clients and make sure there's only 1 entry, and it matches the one we've put in. + let result = cache.get_clients(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, ip); + } + + #[test] + fn test_no_cache() { + let cache = ClientCache::new(&[]); + let ip = IpAddr::from_str("192.0.2.1").unwrap(); + + // Increment the client counter 10 times and make sure it returns zero when retrieved. + for _i in 1..=10 { + let result = cache.inc_client(ip); + assert_eq!(result.len(), 0); + let result = cache.get_client(ip); + assert_eq!(result.len(), 0); + } + + // Get the list of clients and make sure there it's empty. + let result = cache.get_clients(); + assert_eq!(result.len(), 0); + + } + + #[test] + fn test_multi_cache() { + let cache = ClientCache::new(&[(10, 2), (10, 10), (10, 1)]); + + // Confirm that the caches are sorted by descending TTL + assert_eq!(cache.get_ttls(), &[10, 2, 1]); + + // Increment a client's counter a few times and make sure it returns the + // same count for each cache. + let ip = IpAddr::from_str("192.0.2.1").unwrap(); + for i in 1..=10 { + let result = cache.inc_client(ip); + assert_eq!(result.len(), 3); + let result = cache.get_client(ip); + assert_eq!(result[0], i); + assert_eq!(result[1], i); + assert_eq!(result[2], i); + } + + // Wait for the 3rd cache to expire and make sure the client is no longer + // present in the 3rd cache but present in the others. + std::thread::sleep(std::time::Duration::from_millis(1_001)); + let result = cache.get_client(ip); + assert_eq!(result[0], 10); + assert_eq!(result[1], 10); + assert_eq!(result[2], 0); + + // Get the list of clients and make sure there are only 2 entries, and the IP + // is present in them. + let result = cache.get_clients(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].0, ip); + assert_eq!(result[1].0, ip); + + // Wait for the 2nd cache to expire and make sure the client is no longer + // present in the 2nd cache but present in the others. + std::thread::sleep(std::time::Duration::from_millis(1_001)); + let result = cache.get_client(ip); + assert_eq!(result[0], 10); + assert_eq!(result[1], 0); + assert_eq!(result[2], 0); + } + + // WARNING: This test seems rather timing sensitive. The sleep durations have been + // chosen to try to favour the second set of IP addresses, but it may not always work. + #[test] + fn test_cache_limit() { + let cache = ClientCache::new(&[(10, 60)]); + + // Add 10 clients with contiguous IP addresses + for i in 1..=10 { + let ip = IpAddr::from_str(&format!("192.0.2.{}", i)).unwrap(); + cache.inc_client(ip); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + // Add 10 more clients with contiguous IP addresses + for i in 11..=20 { + let ip = IpAddr::from_str(&format!("192.0.2.{}", i)).unwrap(); + cache.inc_client(ip); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + + // Check that the last 10 IPs are present + for i in 11..=20 { + let ip = IpAddr::from_str(&format!("192.0.2.{}", i)).unwrap(); + let result = cache.get_client(ip); + assert_eq!(result[0], 1); + } + + // Increment those again + for i in 11..=20 { + let ip = IpAddr::from_str(&format!("192.0.2.{}", i)).unwrap(); + cache.inc_client(ip); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + // Force cache eviction + cache.run_pending_tasks(); + + // Display all clients + let clients = cache.get_clients(); + println!("Cached clients: {:?}", clients); + + // Check that the cache has at most 10 entries + let counts = cache.get_counts(); + assert_eq!(counts.len(), 1); + assert!(counts[0] <= 10, "Cache should have at most 10 entries, got {}", counts[0]); + + // Check that the last 10 IPs are present (192.0.2.11 to 192.0.2.20) + for i in 11..=20 { + let ip = IpAddr::from_str(&format!("192.0.2.{}", i)).unwrap(); + let result = cache.get_client(ip); + assert!(result[0] > 0, "Cache for {} should be > 0, got {}", ip, result[0]); + } + + // Check that the first 10 IPs are evicted (192.0.2.1 to 192.0.2.10) + for i in 1..=10 { + let ip = IpAddr::from_str(&format!("192.0.2.{}", i)).unwrap(); + let result = cache.get_client(ip); + assert_eq!(result[0], 0); + } + } +} diff --git a/src/metrics/events.rs b/src/metrics/events.rs new file mode 100644 index 0000000..2bff982 --- /dev/null +++ b/src/metrics/events.rs @@ -0,0 +1,32 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PacketEvent { + ClientInvalidResponse, + ClientReceiveFailed, + ClientResponseReceived, + ClientSendFailed, + ClientRequestSent, + ServerPacketTooShort, + ServerReceiveFailed, + ServerRequestReceived, + ServerSendFailed, + ServerResponseSent, + ServerUnsupportedVersion, +} + +impl PacketEvent { + pub fn as_str(&self) -> &'static str { + match self { + PacketEvent::ClientInvalidResponse => "client_invalid_response", + PacketEvent::ClientReceiveFailed => "client_receive_failed", + PacketEvent::ClientResponseReceived => "client_response_received", + PacketEvent::ClientSendFailed => "client_send_failed", + PacketEvent::ClientRequestSent => "client_request_sent", + PacketEvent::ServerPacketTooShort => "server_packet_too_short", + PacketEvent::ServerReceiveFailed => "server_receive_failed", + PacketEvent::ServerRequestReceived => "server_request_received", + PacketEvent::ServerSendFailed => "server_send_failed", + PacketEvent::ServerResponseSent => "server_response_sent", + PacketEvent::ServerUnsupportedVersion => "server_unsupported_version", + } + } +} diff --git a/src/metrics/http_server.rs b/src/metrics/http_server.rs new file mode 100644 index 0000000..947f4a9 --- /dev/null +++ b/src/metrics/http_server.rs @@ -0,0 +1,93 @@ +use crate::metrics::MetricsCollector; +use prometheus_client::encoding::text::encode; +use std::sync::Arc; +use std::thread; +use tiny_http::{Server, Response, Header}; + +#[derive(Clone)] +pub struct MetricsServer { + metrics: Arc, + address: String, +} + +impl MetricsServer { + pub fn new(metrics: Arc, address: String) -> Self { + Self { metrics, address } + } + + // Check for a URL parameter named `param_name` of type `T` and return its value, if any + fn parse_query_param(url: &str, param_name: &str) -> Option { + if url.contains('?') { + url.split('?').nth(1) + .and_then(|query| { + query.split('&') + .find(|param| param.starts_with(&format!("{}=", param_name))) + .and_then(|param| param.split('=').nth(1)) + .and_then(|value| value.parse::().ok()) + }) + } else { + None + } + } + + // Print out all the clients and their cache counters in CSV format + fn handle_clients(&self, period: Option) -> Response>> { + let mut clients = self.metrics.client_cache.get_clients_with_counters(period); + clients.sort_by_key(|(addr, _)| *addr); + let mut output = clients + .iter() + .map(|(addr, counters)| { + let counter_str = counters.iter().map(|c| c.to_string()).collect::>().join(","); + format!("{},{}", addr, counter_str) + }) + .collect::>() + .join("\n"); + + if !output.is_empty() { + output.push('\n'); + } + + Response::from_string(output) + .with_header("Content-Type: text/plain; version=0.0.4; charset=utf-8".parse::
().unwrap()) + } + + // Print out all of the metrics in prometheus format + fn handle_metrics(&self) -> Response>> { + self.metrics.update_process_metrics(); + self.metrics.update_unique_clients(); + let mut buffer = String::new(); + match encode(&mut buffer, &self.metrics.registry()) { + Ok(_) => Response::from_string(buffer) + .with_header("Content-Type: text/plain; version=0.0.4; charset=utf-8".parse::
().unwrap()), + Err(_) => Response::from_string("Failed to encode metrics").with_status_code(500), + } + } + + pub fn start(&self) -> Result<(), Box> { + let server = Server::http(&self.address)?; + println!("Metrics server listening on http://{}", self.address); + + for request in server.incoming_requests() { + let metrics_server = self.clone(); + thread::spawn(move || { + let response = match request.method() { + &tiny_http::Method::Get => { + let url = request.url(); + if url.starts_with("/clients") { + let period = Self::parse_query_param::(url, "period"); + metrics_server.handle_clients(period) + } else if url == "/metrics" { + metrics_server.handle_metrics() + } else { + Response::from_string("Not Found").with_status_code(404) + } + } + _ => Response::from_string("Not Found").with_status_code(404), + }; + let _ = request.respond(response); + }); + } + + Ok(()) + } +} diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs new file mode 100644 index 0000000..4612a42 --- /dev/null +++ b/src/metrics/mod.rs @@ -0,0 +1,260 @@ +pub mod client_cache; +pub mod events; +pub mod http_server; +pub mod process; + +pub use self::http_server::MetricsServer; + +use crate::metrics::client_cache::ClientCache; +use crate::metrics::events::PacketEvent; +use crate::metrics::process::ProcessMetrics; +use prometheus_client::encoding::EncodeLabelSet; +use prometheus_client::metrics::counter::Counter; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client::metrics::histogram::Histogram; +use prometheus_client::metrics::family::Family; +use prometheus_client::registry::Registry; +use std::net::IpAddr; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct PacketLabels { + thread_id: String, + packet_event: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct ClientLabels { + period: String, +} + +pub struct MetricsCollector { + pub client_cache: ClientCache, + first_seen_gauge: Family, + last_seen_gauge: Family, + packet_counter: Family, + packet_size_histogram: Histogram, + process_metrics: Mutex, + registry: Arc, + unique_clients_gauge: Family, +} + +impl MetricsCollector { + pub fn new(cache_configs: &[(u64, u64)]) -> Self { + let mut registry = Registry::default(); + + let client_cache = ClientCache::new(cache_configs); + let first_seen_gauge = Family::::default(); + let last_seen_gauge = Family::::default(); + let packet_counter = Family::::default(); + let packet_size_histogram = Histogram::new(vec![47.0, 55.0, 127.0].into_iter()); + let unique_clients_gauge = Family::::default(); + + registry.register( + "rsntp_first_seen_time", + "First time each packet event was seen (Unix nanoseconds)", + first_seen_gauge.clone(), + ); + registry.register( + "rsntp_last_seen_time", + "Last time each packet event was seen (Unix nanoseconds)", + last_seen_gauge.clone(), + ); + registry.register( + "rsntp_packets", + "NTP packet event counters", + packet_counter.clone(), + ); + registry.register( + "rsntp_packet_size_bytes", + "NTP packet size in bytes", + packet_size_histogram.clone(), + ); + registry.register( + "rsntp_unique_clients", + "Number of unique client IP addresses by time period", + unique_clients_gauge.clone(), + ); + + let process_metrics = ProcessMetrics::new(&mut registry); + + Self { + registry: Arc::new(registry), + packet_counter, + first_seen_gauge, + last_seen_gauge, + packet_size_histogram, + process_metrics: Mutex::new(process_metrics), + unique_clients_gauge, + client_cache, + } + } + + pub fn registry(&self) -> Arc { + self.registry.clone() + } + + fn current_time_nanos() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as i64 + } + + pub fn increment_packet_counter(&self, event: PacketEvent, thread_id: u32) { + let labels = PacketLabels { + thread_id: thread_id.to_string(), + packet_event: event.as_str().to_string(), + }; + self.packet_counter.get_or_create(&labels).inc(); + } + + pub fn update_first_seen_time(&self, event: PacketEvent, thread_id: u32) { + let current_time = Self::current_time_nanos(); + let labels = PacketLabels { + thread_id: thread_id.to_string(), + packet_event: event.as_str().to_string(), + }; + + let gauge = self.first_seen_gauge.get_or_create(&labels); + // Only set if not already set (first time) + if gauge.get() == 0 { + gauge.set(current_time); + } + } + + pub fn update_last_seen_time(&self, event: PacketEvent, thread_id: u32) { + let current_time = Self::current_time_nanos(); + let labels = PacketLabels { + thread_id: thread_id.to_string(), + packet_event: event.as_str().to_string(), + }; + + self.last_seen_gauge.get_or_create(&labels).set(current_time); + } + + pub fn record_packet_size(&self, size_bytes: usize) { + self.packet_size_histogram.observe(size_bytes as f64); + } + + pub fn inc_client(&self, ip: IpAddr) { + self.client_cache.inc_client(ip); + } + + pub fn update_packet_counter(&self, event: PacketEvent, thread_id: u32) { + self.increment_packet_counter(event, thread_id); + self.update_first_seen_time(event, thread_id); + self.update_last_seen_time(event, thread_id); + } + + // set unique_clients_gauge for each period to the count of elements in that period's cache + pub fn update_unique_clients(&self) { + for (count, period) in self.client_cache.iter_counts_ttls() { + let labels = ClientLabels { + period: period.to_string(), + }; + let gauge = self.unique_clients_gauge.get_or_create(&labels); + gauge.set(count as i64); + } + } + + pub fn update_process_metrics(&self) { + if let Ok(mut process_metrics) = self.process_metrics.lock() { + process_metrics.update(); + } + } + +} + +#[cfg(test)] +mod tests { + extern crate regex; + use prometheus_client::encoding::text::encode; + use self::regex::Regex; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use super::*; + + #[test] + fn test_increment_packet_counter() { + let collector = MetricsCollector::new(&[(100, 60), (1000, 3600), (10000, 86400)]); + collector.increment_packet_counter(PacketEvent::ServerRequestReceived, 1); + // Test passes if no panic occurs + } + + #[test] + fn test_update_first_seen_time() { + let collector = MetricsCollector::new(&[(100, 60), (1000, 3600), (10000, 86400)]); + collector.update_first_seen_time(PacketEvent::ServerRequestReceived, 1); + // Test passes if no panic occurs + } + + #[test] + fn test_update_last_seen_time() { + let collector = MetricsCollector::new(&[(100, 60), (1000, 3600), (10000, 86400)]); + collector.update_last_seen_time(PacketEvent::ServerRequestReceived, 1); + // Test passes if no panic occurs + } + + #[test] + fn test_record_packet_size() { + let collector = MetricsCollector::new(&[(100, 60), (1000, 3600), (10000, 86400)]); + collector.record_packet_size(48); + collector.record_packet_size(128); + // Test passes if no panic occurs + } + + #[test] + fn test_add_client_ip_v4() { + let collector = MetricsCollector::new(&[(100, 60), (1000, 3600), (10000, 86400)]); + let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); + collector.inc_client(ip); + // Test passes if no panic occurs + } + + #[test] + fn test_add_client_ip_v6() { + let collector = MetricsCollector::new(&[(100, 60), (1000, 3600), (10000, 86400)]); + let ip = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)); + collector.inc_client(ip); + // Test passes if no panic occurs + } + + #[test] + fn test_add_client_ip_disabled_cache() { + let collector = MetricsCollector::new(&[]); + let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); + collector.inc_client(ip); + // Test passes if no panic occurs + } + + #[test] + fn test_update_packet_counter() { + let collector = MetricsCollector::new(&[(100, 60), (1000, 3600), (10000, 86400)]); + collector.update_packet_counter(PacketEvent::ServerRequestReceived, 1); + // Test passes if no panic occurs + } + + #[test] + fn test_registry_access() { + let collector = MetricsCollector::new(&[]); + let registry = collector.registry(); + collector.record_packet_size(48); + collector.record_packet_size(128); + collector.increment_packet_counter(PacketEvent::ServerRequestReceived, 1); + + // test basic registry output + let mut buffer = String::new(); + let _ = encode(&mut buffer, ®istry); + assert!(buffer.contains("rsntp_packets_total")); + assert!(buffer.contains("# TYPE rsntp_packet_size_bytes histogram")); + + // we should have 48 + 128 bytes total, in 2 packets + let expected_sum = (48 + 128) as f64; + let re = Regex::new(&format!(r"(?m)^rsntp_packet_size_bytes_sum {:.1}$", expected_sum)).unwrap(); + assert!(re.is_match(&buffer)); + let re = Regex::new(r"(?m)^rsntp_packet_size_bytes_count 2$").unwrap(); + assert!(re.is_match(&buffer)); + } + +} diff --git a/src/metrics/process.rs b/src/metrics/process.rs new file mode 100644 index 0000000..a792951 --- /dev/null +++ b/src/metrics/process.rs @@ -0,0 +1,121 @@ +use prometheus_client::encoding::EncodeLabelSet; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client::registry::Registry; +use rustc_version_runtime::version; +use std::time::{SystemTime, UNIX_EPOCH}; +use sysinfo::System; + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct RuntimeLabels { + rsntp_version: String, + rust_version: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct MemoryLabels { + memory_type: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct FdLabels { + fd_type: String, +} + +pub struct ProcessMetrics { + fds: prometheus_client::metrics::family::Family, + memory_bytes: prometheus_client::metrics::family::Family, + process_start_time: u64, + runtime_seconds: prometheus_client::metrics::family::Family, + system: System, + threads: Gauge, +} + +impl ProcessMetrics { + pub fn new(registry: &mut Registry) -> Self { + let mut system = System::new_all(); + system.refresh_all(); + + let process_start_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let fds = prometheus_client::metrics::family::Family::default(); + let memory_bytes = prometheus_client::metrics::family::Family::default(); + let runtime_seconds = prometheus_client::metrics::family::Family::default(); + let threads = Gauge::default(); + + registry.register("rsntp_process_fds", "Process file descriptors by type", fds.clone()); + registry.register("rsntp_process_memory_bytes", "Process memory usage in bytes by type", memory_bytes.clone()); + registry.register("rsntp_process_runtime_seconds", "Process runtime in seconds with version info", runtime_seconds.clone()); + registry.register("rsntp_process_threads", "Number of OS threads in the process", threads.clone()); + + Self { + fds, + memory_bytes, + process_start_time, + runtime_seconds, + system, + threads, + } + } + + pub fn update(&mut self) { + self.system.refresh_all(); + + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let runtime = current_time - self.process_start_time; + + let runtime_labels = RuntimeLabels { + rsntp_version: env!("CARGO_PKG_VERSION").to_string(), + rust_version: version().to_string(), + }; + self.runtime_seconds.get_or_create(&runtime_labels).set(runtime as i64); + + // File descriptor information (Linux-specific) + if let Ok(open_fds) = std::fs::read_dir("/proc/self/fd") { + let labels = FdLabels { fd_type: "open".to_string() }; + self.fds.get_or_create(&labels).set(open_fds.count() as i64); + } + + if let Ok(limits) = std::fs::read_to_string("/proc/self/limits") { + for line in limits.lines() { + if line.starts_with("Max open files") { + if let Some(parts) = line.split_whitespace().nth(3) { + if let Ok(max_fds) = parts.parse::() { + let labels = FdLabels { fd_type: "max".to_string() }; + self.fds.get_or_create(&labels).set(max_fds); + break; + } + } + } + } + } + + // Parse thread count and all Vm* memory metrics from /proc/self/status + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + for line in status.lines() { + if line.starts_with("Threads:") { + if let Some(count_str) = line.split_whitespace().nth(1) { + if let Ok(count) = count_str.parse::() { + self.threads.set(count); + } + } + } else if line.starts_with("Vm") { + if let Some(colon_pos) = line.find(':') { + let memory_type = line[2..colon_pos].to_lowercase(); + if let Some(kb_str) = line.split_whitespace().nth(1) { + if let Ok(kb) = kb_str.parse::() { + let labels = MemoryLabels { memory_type }; + self.memory_bytes.get_or_create(&labels).set(kb * 1024); + } + } + } + } + } + } + } +}