From 977bdcc63c5f5b07f3ac1b0d7d3fb7c5c9dc3aa8 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:22:21 -0300 Subject: [PATCH 01/14] =?UTF-8?q?feat(http):=20HttpCheck=20=E2=80=94=20sta?= =?UTF-8?q?tus-set=20grammar,=20body=20checks,=20cert-date=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/visor_http_client/CMakeLists.txt | 6 +- libs/visor_http_client/HttpCheck.cpp | 102 +++++++++++++++++++++ libs/visor_http_client/HttpCheck.h | 38 ++++++++ libs/visor_http_client/test_http_check.cpp | 61 ++++++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 libs/visor_http_client/HttpCheck.cpp create mode 100644 libs/visor_http_client/HttpCheck.h create mode 100644 libs/visor_http_client/test_http_check.cpp diff --git a/libs/visor_http_client/CMakeLists.txt b/libs/visor_http_client/CMakeLists.txt index 11857bfc3..11184933a 100644 --- a/libs/visor_http_client/CMakeLists.txt +++ b/libs/visor_http_client/CMakeLists.txt @@ -5,7 +5,7 @@ find_package(uvw REQUIRED) find_package(httplib REQUIRED) find_package(Catch2 REQUIRED) -add_library(VisorHttpClient STATIC HttpClient.cpp) +add_library(VisorHttpClient STATIC HttpClient.cpp HttpCheck.cpp) # Namespaced alias for consistency with the other libs (Visor::Lib::Dns, Visor::Lib::Tcp, ...) # and cleaner downstream consumption. add_library(Visor::Lib::Http ALIAS VisorHttpClient) @@ -15,3 +15,7 @@ target_link_libraries(VisorHttpClient PUBLIC CURL::libcurl uvw::uvw) add_executable(unit-tests-visor-http-client test_http_client.cpp) target_link_libraries(unit-tests-visor-http-client PRIVATE VisorHttpClient Catch2::Catch2WithMain httplib::httplib) add_test(NAME unit-tests-visor-http-client COMMAND unit-tests-visor-http-client) + +add_executable(unit-tests-visor-http-check test_http_check.cpp) +target_link_libraries(unit-tests-visor-http-check PRIVATE VisorHttpClient Catch2::Catch2WithMain) +add_test(NAME unit-tests-visor-http-check COMMAND unit-tests-visor-http-check) diff --git a/libs/visor_http_client/HttpCheck.cpp b/libs/visor_http_client/HttpCheck.cpp new file mode 100644 index 000000000..1595c04d9 --- /dev/null +++ b/libs/visor_http_client/HttpCheck.cpp @@ -0,0 +1,102 @@ +#include "HttpCheck.h" +#include // curl_getdate (cpp only — the header stays curl-free) +#include + +namespace visor::http { + +static void set_range(std::vector &codes, unsigned lo, unsigned hi, const std::string &entry) +{ + if (lo < 100 || hi > 599 || lo > hi) { + throw std::invalid_argument("invalid status entry '" + entry + "' (codes must be 100-599, ranges low-high)"); + } + for (unsigned c = lo; c <= hi; ++c) { + codes[c] = true; + } +} + +StatusMatcher StatusMatcher::parse(const std::vector &entries) +{ + StatusMatcher m; + for (const auto &e : entries) { + if (e.size() == 3 && (e[1] == 'x' || e[1] == 'X') && (e[2] == 'x' || e[2] == 'X') && e[0] >= '1' && e[0] <= '5') { + unsigned cls = static_cast(e[0] - '0'); + set_range(m._codes, cls * 100, cls * 100 + 99, e); + } else if (auto dash = e.find('-'); dash != std::string::npos && dash > 0 && dash < e.size() - 1) { + unsigned lo{}, hi{}; + try { + size_t p1{}, p2{}; + lo = static_cast(std::stoul(e.substr(0, dash), &p1)); + hi = static_cast(std::stoul(e.substr(dash + 1), &p2)); + if (p1 != dash || p2 != e.size() - dash - 1) { + throw std::invalid_argument(e); + } + } catch (const std::exception &) { + throw std::invalid_argument("invalid status entry '" + e + "'"); + } + set_range(m._codes, lo, hi, e); + } else { + unsigned code{}; + try { + size_t pos{}; + code = static_cast(std::stoul(e, &pos)); + if (pos != e.size()) { + throw std::invalid_argument(e); + } + } catch (const std::exception &) { + throw std::invalid_argument("invalid status entry '" + e + "'"); + } + set_range(m._codes, code, code, e); + } + m._empty = false; + } + return m; +} + +bool StatusMatcher::matches(uint16_t status) const +{ + return !_empty && status < _codes.size() && _codes[status]; +} + +bool StatusMatcher::empty() const +{ + return _empty; +} + +BodyCheck BodyCheck::compile(const std::string &substr, const std::string ®ex_pattern) +{ + BodyCheck b; + b.substring = substr; + if (!regex_pattern.empty()) { + try { + b.regex.emplace(regex_pattern, std::regex::ECMAScript); + } catch (const std::regex_error &) { + // never quote the pattern — it can embed secrets + throw std::invalid_argument("expected_body_regex is not a valid ECMAScript regular expression"); + } + } + return b; +} + +bool BodyCheck::matches(const std::string &body) const +{ + if (!substring.empty() && body.find(substring) == std::string::npos) { + return false; + } + if (regex.has_value() && !std::regex_search(body, *regex)) { + return false; + } + return true; +} + +uint64_t parse_cert_expire_date(const std::string &date_str) +{ + // curl CERTINFO format, e.g. "Aug 15 12:00:00 2026 GMT" (day may be space-padded). + // curl_getdate() is token-based (handles month-name/day/time/year/zone in any order) and, + // unlike strptime/timegm, is fully portable incl. MSVC — netprobe builds on win64. + if (date_str.empty()) { + return 0; + } + time_t t = curl_getdate(date_str.c_str(), nullptr); + return t > 0 ? static_cast(t) : 0; +} +} diff --git a/libs/visor_http_client/HttpCheck.h b/libs/visor_http_client/HttpCheck.h new file mode 100644 index 000000000..9c0e3c51a --- /dev/null +++ b/libs/visor_http_client/HttpCheck.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace visor::http { + +// Parsed set of HTTP status codes (entries: "NNN", "Nxx", "A-B"). Throws std::invalid_argument +// naming the bad ENTRY (never other config values) on grammar violations. +class StatusMatcher +{ +public: + StatusMatcher() = default; // empty (matches nothing); empty() == true + static StatusMatcher parse(const std::vector &entries); // throws std::invalid_argument + bool matches(uint16_t status) const; + bool empty() const; + +private: + std::vector _codes = std::vector(600, false); // index by status; 100..599 valid + bool _empty{true}; +}; + +// Body-content checks: substring AND regex (each optional). compile() throws std::invalid_argument +// on a bad regex (message must NOT quote the pattern — patterns can embed secrets). +struct BodyCheck { + std::string substring; // empty => not checked + std::optional regex; // nullopt => not checked + bool configured() const { return !substring.empty() || regex.has_value(); } + static BodyCheck compile(const std::string &substr, const std::string ®ex_pattern); // "" => absent + bool matches(const std::string &body) const; // AND of the configured checks +}; + +// Parse a curl CERTINFO "Expire date:" value, e.g. "Aug 15 12:00:00 2026 GMT", to unix epoch. +// Returns 0 on parse failure. (Pure string->epoch; the CERTINFO iteration lives in HttpClient.) +uint64_t parse_cert_expire_date(const std::string &date_str); +} diff --git a/libs/visor_http_client/test_http_check.cpp b/libs/visor_http_client/test_http_check.cpp new file mode 100644 index 000000000..e352218cf --- /dev/null +++ b/libs/visor_http_client/test_http_check.cpp @@ -0,0 +1,61 @@ +#include "HttpCheck.h" +#include +#include + +using namespace visor::http; + +TEST_CASE("StatusMatcher grammar", "[http][check]") +{ + auto m = StatusMatcher::parse({"200", "2xx", "301-303", "429"}); + CHECK(m.matches(200)); + CHECK(m.matches(204)); // via 2xx + CHECK(m.matches(302)); // via range + CHECK(m.matches(429)); + CHECK_FALSE(m.matches(304)); + CHECK_FALSE(m.matches(500)); + CHECK_FALSE(m.empty()); + + CHECK(StatusMatcher{}.empty()); + CHECK_FALSE(StatusMatcher{}.matches(200)); + + CHECK_THROWS_AS(StatusMatcher::parse({"2x"}), std::invalid_argument); + CHECK_THROWS_AS(StatusMatcher::parse({"abc"}), std::invalid_argument); + CHECK_THROWS_AS(StatusMatcher::parse({"600"}), std::invalid_argument); + CHECK_THROWS_AS(StatusMatcher::parse({"99"}), std::invalid_argument); + CHECK_THROWS_AS(StatusMatcher::parse({"300-200"}), std::invalid_argument); + CHECK_THROWS_AS(StatusMatcher::parse({"6xx"}), std::invalid_argument); + CHECK_THROWS_AS(StatusMatcher::parse({""}), std::invalid_argument); + // the error message names the offending entry + CHECK_THROWS_WITH(StatusMatcher::parse({"2x"}), Catch::Matchers::ContainsSubstring("2x")); +} + +TEST_CASE("BodyCheck substring AND regex", "[http][check]") +{ + auto both = BodyCheck::compile("\"status\":\"ok\"", "up|healthy"); + CHECK(both.configured()); + CHECK(both.matches("{\"status\":\"ok\",\"state\":\"up\"}")); + CHECK_FALSE(both.matches("{\"status\":\"ok\"}")); // regex fails + CHECK_FALSE(both.matches("healthy")); // substring fails + + auto sub_only = BodyCheck::compile("ok", ""); + CHECK(sub_only.matches("looks ok")); + CHECK_FALSE(sub_only.matches("nope")); + + auto rx_only = BodyCheck::compile("", "^ready$"); + CHECK(rx_only.matches("ready")); + CHECK_FALSE(rx_only.matches("not ready")); + + CHECK_FALSE(BodyCheck::compile("", "").configured()); + CHECK_THROWS_AS(BodyCheck::compile("", "(unclosed"), std::invalid_argument); +} + +TEST_CASE("parse_cert_expire_date", "[http][check]") +{ + // date -u -j -f "%b %d %T %Y" "Aug 15 12:00:00 2026" +%s => 1786795200 (verified macOS) + CHECK(parse_cert_expire_date("Aug 15 12:00:00 2026 GMT") == 1786795200ULL); + // curl CERTINFO values can carry a leading space; must parse to the same epoch. + CHECK(parse_cert_expire_date(" Aug 15 12:00:00 2026 GMT") == 1786795200ULL); + CHECK(parse_cert_expire_date("Jan 2 03:04:05 2027 GMT") != 0); + CHECK(parse_cert_expire_date("not a date") == 0); + CHECK(parse_cert_expire_date("") == 0); +} From fde76fa54d8f0ec635030e40204893a09e7d86ca Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:26:08 -0300 Subject: [PATCH 02/14] feat(http): transport options (proxy, TLS files, UA, CERTINFO) + response size/cert expiry results --- libs/visor_http_client/HttpClient.cpp | 40 +++++++ libs/visor_http_client/HttpTypes.h | 8 ++ libs/visor_http_client/test_http_client.cpp | 109 ++++++++++++++++++++ 3 files changed, 157 insertions(+) diff --git a/libs/visor_http_client/HttpClient.cpp b/libs/visor_http_client/HttpClient.cpp index bb75528b6..bad124b2e 100644 --- a/libs/visor_http_client/HttpClient.cpp +++ b/libs/visor_http_client/HttpClient.cpp @@ -1,4 +1,6 @@ #include "HttpClient.h" +#include "HttpCheck.h" +#include #include #include #include @@ -174,6 +176,24 @@ void HttpClient::request(const HttpRequest &req, ResultCallback on_done) // We run on the netprobe io thread, not the main thread; CURLOPT_NOSIGNAL stops curl from // using signals (e.g. SIGALRM with the standard name resolver), which is unsafe off-main-thread. curl_easy_setopt(easy, CURLOPT_NOSIGNAL, 1L); + if (!req.user_agent.empty()) { + curl_easy_setopt(easy, CURLOPT_USERAGENT, req.user_agent.c_str()); + } + if (!req.proxy.empty()) { + curl_easy_setopt(easy, CURLOPT_PROXY, req.proxy.c_str()); + } + if (!req.ca_file.empty()) { + curl_easy_setopt(easy, CURLOPT_CAINFO, req.ca_file.c_str()); + } + if (!req.cert_file.empty()) { + curl_easy_setopt(easy, CURLOPT_SSLCERT, req.cert_file.c_str()); + } + if (!req.key_file.empty()) { + curl_easy_setopt(easy, CURLOPT_SSLKEY, req.key_file.c_str()); + } + if (req.collect_cert_info) { + curl_easy_setopt(easy, CURLOPT_CERTINFO, 1L); + } if (!req.body.empty()) { // COPYPOSTFIELDS copies the bytes (curl owns them); size set first => binary-safe. curl_easy_setopt(easy, CURLOPT_POSTFIELDSIZE, static_cast(req.body.size())); @@ -353,6 +373,26 @@ void HttpClient::check_multi_info() result.timings.connect_us = conn > dns ? static_cast(conn - dns) : 0; result.timings.tls_us = app > conn ? static_cast(app - conn) : 0; result.timings.ttfb_us = ttfb > (app ? app : conn) ? static_cast(ttfb - (app ? app : conn)) : 0; + curl_off_t dl_size = 0; + curl_easy_getinfo(easy, CURLINFO_SIZE_DOWNLOAD_T, &dl_size); + result.response_size = dl_size > 0 ? static_cast(dl_size) : 0; + struct curl_certinfo *ci = nullptr; + if (curl_easy_getinfo(easy, CURLINFO_CERTINFO, &ci) == CURLE_OK && ci) { + // earliest notAfter across the presented chain (blackbox_exporter semantics) + uint64_t earliest = 0; + for (int i = 0; i < ci->num_of_certs; ++i) { + for (auto *sl = ci->certinfo[i]; sl; sl = sl->next) { + constexpr char kPrefix[] = "Expire date:"; + if (sl->data && std::strncmp(sl->data, kPrefix, sizeof(kPrefix) - 1) == 0) { + uint64_t e = parse_cert_expire_date(std::string(sl->data + sizeof(kPrefix) - 1)); + if (e && (earliest == 0 || e < earliest)) { + earliest = e; + } + } + } + } + result.cert_expiry_epoch = earliest; + } if (it != _easy.end() && it->second->capture) { result.response_body = std::move(it->second->response); } diff --git a/libs/visor_http_client/HttpTypes.h b/libs/visor_http_client/HttpTypes.h index 797f29e70..b88c53743 100644 --- a/libs/visor_http_client/HttpTypes.h +++ b/libs/visor_http_client/HttpTypes.h @@ -21,6 +21,12 @@ struct HttpRequest { std::string body; // request body bytes (empty => no body) std::vector headers; // extra request headers, each "Key: Value" bool capture_response{false}; // when true, capture the response body + bool collect_cert_info{false}; // when true, request CURLOPT_CERTINFO and populate HttpResult.cert_expiry_epoch + std::string proxy; // CURLOPT_PROXY value (empty => no proxy) + std::string ca_file; // CURLOPT_CAINFO (empty => curl default CA bundle) + std::string cert_file; // CURLOPT_SSLCERT (client cert, empty => none) + std::string key_file; // CURLOPT_SSLKEY (client key, empty => none) + std::string user_agent; // CURLOPT_USERAGENT (empty => curl default) }; struct HttpResult { bool transport_ok{false}; @@ -30,5 +36,7 @@ struct HttpResult { std::string response_body; // populated only when HttpRequest.capture_response std::string content_type; // raw response Content-Type header when transport_ok (compare case-insensitively) std::string error_msg; // human-readable curl error detail when !transport_ok + uint64_t cert_expiry_epoch{0}; // earliest "Expire date:" across the TLS chain when HttpRequest.collect_cert_info; 0 for plain http or on parse failure + uint64_t response_size{0}; // CURLINFO_SIZE_DOWNLOAD_T; populated on every transport_ok, independent of capture_response }; } diff --git a/libs/visor_http_client/test_http_client.cpp b/libs/visor_http_client/test_http_client.cpp index 62365f11f..a44f70594 100644 --- a/libs/visor_http_client/test_http_client.cpp +++ b/libs/visor_http_client/test_http_client.cpp @@ -19,6 +19,9 @@ static int start_test_server(httplib::Server &svr, std::thread &t) svr.Post("/echo", [](const httplib::Request &req, httplib::Response &res) { res.set_content(req.body, "application/octet-stream"); }); + svr.Get("/ua", [](const httplib::Request &req, httplib::Response &res) { + res.set_content(req.get_header_value("User-Agent"), "text/plain"); + }); int port = svr.bind_to_any_port("127.0.0.1"); REQUIRE(port > 0); t = std::thread([&svr] { svr.listen_after_bind(); }); @@ -56,6 +59,18 @@ static void disarm_watchdog(std::shared_ptr loop, std::shared_ptr results; + auto on_done = [&](const HttpResult &r) { results.push_back(r); }; + + SECTION("user_agent is sent; response_size populated") + { + HttpRequest req; + req.url = base + "/ua"; + req.user_agent = "pktvisor-test/1.0"; + req.capture_response = true; + req.timeout_ms = 2000; + client.request(req, on_done); + auto wd = arm_watchdog(loop, 5000); + loop->run(); + disarm_watchdog(loop, wd); + REQUIRE(results.size() == 1); + CHECK(results[0].response_body == "pktvisor-test/1.0"); + CHECK(results[0].response_size == results[0].response_body.size()); + } + SECTION("plain http => cert_expiry_epoch stays 0 even when requested") + { + HttpRequest req; + req.url = base + "/ok"; + req.collect_cert_info = true; + req.timeout_ms = 2000; + client.request(req, on_done); + auto wd = arm_watchdog(loop, 5000); + loop->run(); + disarm_watchdog(loop, wd); + REQUIRE(results.size() == 1); + CHECK(results[0].cert_expiry_epoch == 0); + } + SECTION("tls option wiring: ca/cert/key fields on a plain-http request are harmless") + { + // Wiring smoke: the setopts are applied without crashing and don't affect a plain-http + // transfer (TLS options are simply unused). Real TLS validation is a manual smoke (README). + HttpRequest req; + req.url = base + "/ok"; + req.ca_file = "/nonexistent/ca.pem"; // paths need not exist for a plain-http transfer + req.cert_file = "/nonexistent/c.pem"; + req.key_file = "/nonexistent/k.pem"; + req.timeout_ms = 2000; + client.request(req, on_done); + auto wd = arm_watchdog(loop, 5000); + loop->run(); + disarm_watchdog(loop, wd); + REQUIRE(results.size() == 1); + CHECK(results[0].transport_ok); + CHECK(results[0].status_code == 200); + } + SECTION("proxy: request goes THROUGH the forward proxy (absolute-form URI)") + { + httplib::Server proxy_srv; + std::string seen_path; + // A plain-http forward proxy receives the absolute-form request target; a regex + // catch-all lets httplib serve it and prove the request really went via the proxy. + proxy_srv.Get(R"((.*))", [&](const httplib::Request &preq, httplib::Response &pres) { + seen_path = preq.path; + pres.set_content("via-proxy", "text/plain"); + }); + int pport = proxy_srv.bind_to_any_port("127.0.0.1"); + REQUIRE(pport > 0); + std::thread pthread([&proxy_srv] { proxy_srv.listen_after_bind(); }); + ServerGuard pguard{proxy_srv, pthread}; + proxy_srv.wait_until_ready(); + + HttpRequest req; + req.url = "http://192.0.2.1/unreachable-without-proxy"; // TEST-NET, unroutable directly + req.proxy = "http://127.0.0.1:" + std::to_string(pport); + req.capture_response = true; + req.timeout_ms = 2000; + client.request(req, on_done); + auto wd = arm_watchdog(loop, 5000); + loop->run(); + disarm_watchdog(loop, wd); + REQUIRE(results.size() == 1); + CHECK(results[0].transport_ok); + CHECK(results[0].response_body == "via-proxy"); + CHECK(seen_path.find("http://192.0.2.1") == 0); // absolute-form proves proxying + } + + client.close(); + loop->run(); + svr.stop(); + if (server_thread.joinable()) server_thread.join(); +} From 284de3555854531868f29a29483082508545c173 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:30:30 -0300 Subject: [PATCH 03/14] refactor(netprobe): carry http probe results as HttpSample; doh gains cert_expiry passthrough --- libs/visor_http_client/HttpTypes.h | 8 +++ .../netprobe/NetProbeStreamHandler.cpp | 36 +++++----- src/handlers/netprobe/NetProbeStreamHandler.h | 12 ++-- src/handlers/netprobe/test_net_probe.cpp | 70 +++++++++++++------ src/inputs/netprobe/DohProbe.cpp | 2 +- src/inputs/netprobe/DohProbe.h | 4 +- src/inputs/netprobe/HttpProbe.cpp | 9 ++- src/inputs/netprobe/HttpProbe.h | 2 +- src/inputs/netprobe/NetProbeInputStream.cpp | 14 ++-- src/inputs/netprobe/NetProbeInputStream.h | 16 ++--- 10 files changed, 109 insertions(+), 64 deletions(-) diff --git a/libs/visor_http_client/HttpTypes.h b/libs/visor_http_client/HttpTypes.h index b88c53743..f0b3a303a 100644 --- a/libs/visor_http_client/HttpTypes.h +++ b/libs/visor_http_client/HttpTypes.h @@ -39,4 +39,12 @@ struct HttpResult { uint64_t cert_expiry_epoch{0}; // earliest "Expire date:" across the TLS chain when HttpRequest.collect_cert_info; 0 for plain http or on parse failure uint64_t response_size{0}; // CURLINFO_SIZE_DOWNLOAD_T; populated on every transport_ok, independent of capture_response }; +struct HttpSample { + uint16_t status{0}; + bool status_ok{false}; // check evaluation happens in the PROBE + uint8_t content_check{0}; // 0 = NotChecked, 1 = Match, 2 = Mismatch + uint64_t cert_expiry_epoch{0}; + uint64_t response_size{0}; + HttpTimings timings; +}; } diff --git a/src/handlers/netprobe/NetProbeStreamHandler.cpp b/src/handlers/netprobe/NetProbeStreamHandler.cpp index e11b13d0b..580033828 100644 --- a/src/handlers/netprobe/NetProbeStreamHandler.cpp +++ b/src/handlers/netprobe/NetProbeStreamHandler.cpp @@ -118,14 +118,14 @@ void NetProbeStreamHandler::probe_signal_fail(ErrorType error, TestType type, co } } -void NetProbeStreamHandler::probe_signal_http_result(uint16_t status, visor::http::HttpTimings timings, const std::string &name, timespec stamp) +void NetProbeStreamHandler::probe_signal_http_result(visor::http::HttpSample sample, const std::string &name, timespec stamp) { - _metrics->process_netprobe_http_result(status, timings, name, stamp); + _metrics->process_netprobe_http_result(sample, name, stamp); } -void NetProbeStreamHandler::probe_signal_doh_result(uint16_t http_status, uint8_t rcode, bool parse_ok, visor::http::HttpTimings timings, const std::string &name, timespec stamp) +void NetProbeStreamHandler::probe_signal_doh_result(uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, visor::http::HttpTimings timings, const std::string &name, timespec stamp) { - _metrics->process_netprobe_doh_result(http_status, rcode, parse_ok, timings, name, stamp); + _metrics->process_netprobe_doh_result(http_status, rcode, parse_ok, cert_expiry_epoch, timings, name, stamp); } void NetProbeMetricsBucket::specialized_merge(const AbstractMetricsBucket &o, Metric::Aggregate agg_operator) @@ -528,7 +528,7 @@ void NetProbeMetricsManager::process_filtered(timespec stamp) live_bucket()->process_filtered(); } -void NetProbeMetricsBucket::process_netprobe_http(bool deep, uint16_t status, const visor::http::HttpTimings &timings, const std::string &target) +void NetProbeMetricsBucket::process_netprobe_http(bool deep, const visor::http::HttpSample &sample, const std::string &target) { // Take _mutex like new_transaction — both mutate q_time_us/h_time_us sketches // and the TopN which the scrape thread reads under shared_lock. @@ -541,8 +541,8 @@ void NetProbeMetricsBucket::process_netprobe_http(bool deep, uint16_t status, co // Counters (status outcome) are always recorded when the group is on — // like new_transaction's successes++ (not gated on `deep`). if (group_enabled(group::NetProbeMetrics::Counters)) { - t.top_status_codes.update(std::to_string(status)); - if (status >= 200 && status < 400) { + t.top_status_codes.update(std::to_string(sample.status)); + if (sample.status_ok) { ++t.successes; } else { ++t.http_status_failures; @@ -552,24 +552,24 @@ void NetProbeMetricsBucket::process_netprobe_http(bool deep, uint16_t status, co // Sketches are gated on `deep` (deep sampling) exactly like new_transaction. // Histograms is default-ON and drives response_min_us/max_us via h_time_us. if (deep && group_enabled(group::NetProbeMetrics::Histograms)) { - t.h_time_us.update(timings.total_us); + t.h_time_us.update(sample.timings.total_us); } if (deep && group_enabled(group::NetProbeMetrics::Quantiles)) { - t.q_time_us.update(timings.total_us); + t.q_time_us.update(sample.timings.total_us); } if (deep && group_enabled(group::NetProbeMetrics::HttpResponsePhases)) { - t.q_dns_us.update(timings.dns_us); - t.q_connect_us.update(timings.connect_us); - t.q_tls_us.update(timings.tls_us); - t.q_ttfb_us.update(timings.ttfb_us); + t.q_dns_us.update(sample.timings.dns_us); + t.q_connect_us.update(sample.timings.connect_us); + t.q_tls_us.update(sample.timings.tls_us); + t.q_ttfb_us.update(sample.timings.ttfb_us); } } -void NetProbeMetricsManager::process_netprobe_http_result(uint16_t status, const visor::http::HttpTimings &timings, const std::string &target, timespec stamp) +void NetProbeMetricsManager::process_netprobe_http_result(const visor::http::HttpSample &sample, const std::string &target, timespec stamp) { new_event(stamp); live_bucket()->process_attempts(_deep_sampling_now, target); - live_bucket()->process_netprobe_http(_deep_sampling_now, status, timings, target); + live_bucket()->process_netprobe_http(_deep_sampling_now, sample, target); } void NetProbeMetricsManager::process_netprobe_http_failure(ErrorType error, const std::string &target) @@ -581,7 +581,7 @@ void NetProbeMetricsManager::process_netprobe_http_failure(ErrorType error, cons live_bucket()->process_failure(error, target); } -void NetProbeMetricsBucket::process_netprobe_doh(bool deep, uint16_t http_status, uint8_t rcode, bool parse_ok, const visor::http::HttpTimings &timings, const std::string &target) +void NetProbeMetricsBucket::process_netprobe_doh(bool deep, uint16_t http_status, uint8_t rcode, bool parse_ok, [[maybe_unused]] uint64_t cert_expiry_epoch, const visor::http::HttpTimings &timings, const std::string &target) { std::unique_lock lock(_mutex); @@ -624,11 +624,11 @@ void NetProbeMetricsBucket::process_netprobe_doh(bool deep, uint16_t http_status } } -void NetProbeMetricsManager::process_netprobe_doh_result(uint16_t http_status, uint8_t rcode, bool parse_ok, const visor::http::HttpTimings &timings, const std::string &target, timespec stamp) +void NetProbeMetricsManager::process_netprobe_doh_result(uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, const visor::http::HttpTimings &timings, const std::string &target, timespec stamp) { new_event(stamp); live_bucket()->process_attempts(_deep_sampling_now, target); - live_bucket()->process_netprobe_doh(_deep_sampling_now, http_status, rcode, parse_ok, timings, target); + live_bucket()->process_netprobe_doh(_deep_sampling_now, http_status, rcode, parse_ok, cert_expiry_epoch, timings, target); } void NetProbeMetricsManager::process_netprobe_doh_failure(ErrorType error, const std::string &target) diff --git a/src/handlers/netprobe/NetProbeStreamHandler.h b/src/handlers/netprobe/NetProbeStreamHandler.h index b8ff5c716..7068e15c4 100644 --- a/src/handlers/netprobe/NetProbeStreamHandler.h +++ b/src/handlers/netprobe/NetProbeStreamHandler.h @@ -132,8 +132,8 @@ class NetProbeMetricsBucket final : public visor::AbstractMetricsBucket void process_failure(ErrorType error, const std::string &target); void process_attempts(bool deep, const std::string &target); void new_transaction(bool deep, NetProbeTransaction xact); - void process_netprobe_http(bool deep, uint16_t status, const visor::http::HttpTimings &timings, const std::string &target); - void process_netprobe_doh(bool deep, uint16_t http_status, uint8_t rcode, bool parse_ok, const visor::http::HttpTimings &timings, const std::string &target); + void process_netprobe_http(bool deep, const visor::http::HttpSample &sample, const std::string &target); + void process_netprobe_doh(bool deep, uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, const visor::http::HttpTimings &timings, const std::string &target); }; class NetProbeMetricsManager final : public visor::AbstractMetricsManager @@ -164,9 +164,9 @@ class NetProbeMetricsManager final : public visor::AbstractMetricsManagerprocess_netprobe_http_result(200, timings, "t1", stamp); - fx.manager()->process_netprobe_http_result(404, timings, "t1", stamp); + visor::http::HttpSample s200; + s200.status = 200; + s200.status_ok = (s200.status >= 200 && s200.status < 400); + s200.timings = timings; + visor::http::HttpSample s404; + s404.status = 404; + s404.status_ok = (s404.status >= 200 && s404.status < 400); + s404.timings = timings; + fx.manager()->process_netprobe_http_result(s200, "t1", stamp); + fx.manager()->process_netprobe_http_result(s404, "t1", stamp); fx.manager()->process_netprobe_http_failure(ErrorType::ConnectFailure, "t1"); json j; @@ -465,10 +473,17 @@ TEST_CASE("NetProbe HTTP status boundary: 3xx is success, 1xx and 0 are failures timespec stamp{2000, 0}; auto timings = visor::http::HttpTimings{500, 50, 100, 0, 200}; - fx.manager()->process_netprobe_http_result(301, timings, "redir", stamp); // 3xx → success - fx.manager()->process_netprobe_http_result(500, timings, "redir", stamp); // 5xx → http_status_failures - fx.manager()->process_netprobe_http_result(100, timings, "redir", stamp); // 1xx → http_status_failures - fx.manager()->process_netprobe_http_result(0, timings, "redir", stamp); // 0 → http_status_failures + auto make_sample = [&timings](uint16_t status) { + visor::http::HttpSample s; + s.status = status; + s.status_ok = (status >= 200 && status < 400); + s.timings = timings; + return s; + }; + fx.manager()->process_netprobe_http_result(make_sample(301), "redir", stamp); // 3xx → success + fx.manager()->process_netprobe_http_result(make_sample(500), "redir", stamp); // 5xx → http_status_failures + fx.manager()->process_netprobe_http_result(make_sample(100), "redir", stamp); // 1xx → http_status_failures + fx.manager()->process_netprobe_http_result(make_sample(0), "redir", stamp); // 0 → http_status_failures json j; fx.manager()->bucket(0)->to_json(j); @@ -493,7 +508,11 @@ TEST_CASE("NetProbe HTTP http_response_phases group: quantiles present when enab timespec stamp{3000, 0}; auto timings = visor::http::HttpTimings{2000, 150, 300, 400, 600}; - mgr->process_netprobe_http_result(200, timings, "phases-tgt", stamp); + visor::http::HttpSample sample; + sample.status = 200; + sample.status_ok = (sample.status >= 200 && sample.status < 400); + sample.timings = timings; + mgr->process_netprobe_http_result(sample, "phases-tgt", stamp); json j; mgr->bucket(0)->to_json(j); @@ -514,7 +533,11 @@ TEST_CASE("NetProbe HTTP http_response_phases group: quantiles absent when not e timespec stamp{4000, 0}; auto timings = visor::http::HttpTimings{2000, 150, 300, 400, 600}; - fx.manager()->process_netprobe_http_result(200, timings, "no-phases", stamp); + visor::http::HttpSample sample; + sample.status = 200; + sample.status_ok = (sample.status >= 200 && sample.status < 400); + sample.timings = timings; + fx.manager()->process_netprobe_http_result(sample, "no-phases", stamp); json j; fx.manager()->bucket(0)->to_json(j); @@ -533,9 +556,16 @@ TEST_CASE("NetProbe HTTP metrics merge across buckets", "[netprobe][http][unit]" timespec stamp{5000, 0}; auto timings = visor::http::HttpTimings{1000, 50, 100, 0, 300}; - fx_a.manager()->process_netprobe_http_result(200, timings, "shared", stamp); - fx_a.manager()->process_netprobe_http_result(404, timings, "shared", stamp); - fx_b.manager()->process_netprobe_http_result(500, timings, "shared", stamp); + auto make_sample = [&timings](uint16_t status) { + visor::http::HttpSample s; + s.status = status; + s.status_ok = (status >= 200 && status < 400); + s.timings = timings; + return s; + }; + fx_a.manager()->process_netprobe_http_result(make_sample(200), "shared", stamp); + fx_a.manager()->process_netprobe_http_result(make_sample(404), "shared", stamp); + fx_b.manager()->process_netprobe_http_result(make_sample(500), "shared", stamp); UnitFixture fx_merged(2); auto *merged = const_cast(fx_merged.manager()->bucket(0)); @@ -560,13 +590,13 @@ TEST_CASE("NetProbe DoH DNS-aware metrics: counters and top_rcodes", "[netprobe] auto timings = visor::http::HttpTimings{/*total_us*/ 1234, /*dns_us*/ 100, /*connect_us*/ 200, /*tls_us*/ 300, /*ttfb_us*/ 400}; // 200 + rcode 0 + parse_ok=true → success - fx.manager()->process_netprobe_doh_result(200, 0, true, timings, "t1", stamp); + fx.manager()->process_netprobe_doh_result(200, 0, true, 0, timings, "t1", stamp); // 200 + rcode 2 + parse_ok=true → dns_response_failures (SRVFAIL) - fx.manager()->process_netprobe_doh_result(200, 2, true, timings, "t1", stamp); + fx.manager()->process_netprobe_doh_result(200, 2, true, 0, timings, "t1", stamp); // 200 + rcode 0 + parse_ok=false → dns_response_failures (PARSE_ERROR) - fx.manager()->process_netprobe_doh_result(200, 0, false, timings, "t1", stamp); + fx.manager()->process_netprobe_doh_result(200, 0, false, 0, timings, "t1", stamp); // 503 + parse_ok=false → http_status_failures - fx.manager()->process_netprobe_doh_result(503, 0, false, timings, "t1", stamp); + fx.manager()->process_netprobe_doh_result(503, 0, false, 0, timings, "t1", stamp); // transport failure → connect_failures fx.manager()->process_netprobe_doh_failure(ErrorType::ConnectFailure, "t1"); @@ -625,7 +655,7 @@ TEST_CASE("NetProbe DoH http_response_phases group: quantiles present when enabl timespec stamp{7000, 0}; auto timings = visor::http::HttpTimings{2000, 150, 300, 400, 600}; - mgr->process_netprobe_doh_result(200, 0, true, timings, "doh-phases-tgt", stamp); + mgr->process_netprobe_doh_result(200, 0, true, 0, timings, "doh-phases-tgt", stamp); json j; mgr->bucket(0)->to_json(j); @@ -657,9 +687,9 @@ TEST_CASE("NetProbe DoH top_rcodes honors topn_count", "[netprobe][doh][unit]") auto timings = visor::http::HttpTimings{1000, 0, 0, 0, 0}; // Feed 3 distinct rcodes; with topn_count=2 the settings must be applied to the per-target // TopN, so top_rcodes emits at most 2 entries (without the fix it would emit all 3). - mgr->process_netprobe_doh_result(200, 0, true, timings, "t1", stamp); // NOERROR - mgr->process_netprobe_doh_result(200, 2, true, timings, "t1", stamp); // SRVFAIL - mgr->process_netprobe_doh_result(200, 3, true, timings, "t1", stamp); // NXDOMAIN + mgr->process_netprobe_doh_result(200, 0, true, 0, timings, "t1", stamp); // NOERROR + mgr->process_netprobe_doh_result(200, 2, true, 0, timings, "t1", stamp); // SRVFAIL + mgr->process_netprobe_doh_result(200, 3, true, 0, timings, "t1", stamp); // NXDOMAIN json j; mgr->bucket(0)->to_json(j); @@ -676,7 +706,7 @@ TEST_CASE("NetProbe DoH http_response_phases group: quantiles absent when not en timespec stamp{8000, 0}; auto timings = visor::http::HttpTimings{2000, 150, 300, 400, 600}; - fx.manager()->process_netprobe_doh_result(200, 0, true, timings, "doh-no-phases", stamp); + fx.manager()->process_netprobe_doh_result(200, 0, true, 0, timings, "doh-no-phases", stamp); json j; fx.manager()->bucket(0)->to_json(j); diff --git a/src/inputs/netprobe/DohProbe.cpp b/src/inputs/netprobe/DohProbe.cpp index 75bbf2a87..ca1e7adfb 100644 --- a/src/inputs/netprobe/DohProbe.cpp +++ b/src/inputs/netprobe/DohProbe.cpp @@ -199,7 +199,7 @@ bool DohProbe::start(std::shared_ptr io_loop) } else if (logger) { logger->debug("netprobe doh[{}]: response too short for a DNS message ({} bytes)", name, r.response_body.size()); } - doh_result(static_cast(r.status_code), rcode, parse_ok, r.timings, name, stamp); + doh_result(static_cast(r.status_code), rcode, parse_ok, r.cert_expiry_epoch, r.timings, name, stamp); } else { if (logger) { logger->debug("netprobe doh[{}]: transport error: {} (curl code {})", name, r.error_msg, r.curl_code); diff --git a/src/inputs/netprobe/DohProbe.h b/src/inputs/netprobe/DohProbe.h index 144aad0b2..33e0c3bc3 100644 --- a/src/inputs/netprobe/DohProbe.h +++ b/src/inputs/netprobe/DohProbe.h @@ -10,8 +10,8 @@ namespace visor::input::netprobe { -// http_status, rcode, parse_ok, timings, name, stamp -using DohResultCallback = std::function; +// http_status, rcode, parse_ok, cert_expiry_epoch, timings, name, stamp +using DohResultCallback = std::function; class DohProbe final : public NetProbe { diff --git a/src/inputs/netprobe/HttpProbe.cpp b/src/inputs/netprobe/HttpProbe.cpp index f055933a1..d32d0bb02 100644 --- a/src/inputs/netprobe/HttpProbe.cpp +++ b/src/inputs/netprobe/HttpProbe.cpp @@ -35,7 +35,14 @@ bool HttpProbe::start(std::shared_ptr io_loop) timespec stamp; std::timespec_get(&stamp, TIME_UTC); if (r.transport_ok) { - http_result(static_cast(r.status_code), r.timings, name, stamp); + visor::http::HttpSample s; + s.status = static_cast(r.status_code); + s.status_ok = (s.status >= 200 && s.status < 400); // v1 default; Task 4 replaces with checks + s.content_check = 0; + s.cert_expiry_epoch = r.cert_expiry_epoch; + s.response_size = r.response_size; + s.timings = r.timings; + http_result(s, name, stamp); } else { if (auto logger = spdlog::get("visor")) { logger->debug("netprobe http[{}]: transport error: {} (curl code {})", name, r.error_msg, r.curl_code); diff --git a/src/inputs/netprobe/HttpProbe.h b/src/inputs/netprobe/HttpProbe.h index d9c0b096c..db0b3a841 100644 --- a/src/inputs/netprobe/HttpProbe.h +++ b/src/inputs/netprobe/HttpProbe.h @@ -10,7 +10,7 @@ namespace visor::input::netprobe { -using HttpResultCallback = std::function; +using HttpResultCallback = std::function; class HttpProbe final : public NetProbe { diff --git a/src/inputs/netprobe/NetProbeInputStream.cpp b/src/inputs/netprobe/NetProbeInputStream.cpp index a79654dd0..5e0779620 100644 --- a/src/inputs/netprobe/NetProbeInputStream.cpp +++ b/src/inputs/netprobe/NetProbeInputStream.cpp @@ -250,19 +250,19 @@ void NetProbeInputStream::_fail_cb(ErrorType error, TestType type, const std::st } } -void NetProbeInputStream::_http_result_cb(uint16_t status, visor::http::HttpTimings t, const std::string &name, timespec stamp) +void NetProbeInputStream::_http_result_cb(visor::http::HttpSample sample, const std::string &name, timespec stamp) { std::shared_lock lock(_input_mutex); for (auto &proxy : _event_proxies) { - static_cast(proxy.get())->probe_http_result_cb(status, t, name, stamp); + static_cast(proxy.get())->probe_http_result_cb(sample, name, stamp); } } -void NetProbeInputStream::_doh_result_cb(uint16_t http_status, uint8_t rcode, bool parse_ok, visor::http::HttpTimings t, const std::string &name, timespec stamp) +void NetProbeInputStream::_doh_result_cb(uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, visor::http::HttpTimings t, const std::string &name, timespec stamp) { std::shared_lock lock(_input_mutex); for (auto &proxy : _event_proxies) { - static_cast(proxy.get())->probe_doh_result_cb(http_status, rcode, parse_ok, t, name, stamp); + static_cast(proxy.get())->probe_doh_result_cb(http_status, rcode, parse_ok, cert_expiry_epoch, t, name, stamp); } } @@ -364,7 +364,7 @@ void NetProbeInputStream::_create_netprobe_loop() for (const auto &[key, url] : _http_targets) { auto probe = std::make_unique(_id, key, url, _http_method, _http_client, - [this](uint16_t status, visor::http::HttpTimings t, const std::string &name, timespec stamp) { _http_result_cb(status, t, name, stamp); }); + [this](visor::http::HttpSample sample, const std::string &name, timespec stamp) { _http_result_cb(sample, name, stamp); }); ++_id; probe->set_configs(_interval_msec, _timeout_msec, _packets_per_test, _packets_interval_msec, _packet_payload_size); probe->set_callbacks([this](pcpp::Packet &payload, TestType type, const std::string &name, timespec stamp) { _send_cb(payload, type, name, stamp); }, @@ -376,8 +376,8 @@ void NetProbeInputStream::_create_netprobe_loop() for (const auto &[key, url] : _doh_targets) { auto probe = std::make_unique(_id, key, url, _doh_method, _doh_qname, _doh_qtype, _http_client, - [this](uint16_t http_status, uint8_t rcode, bool parse_ok, visor::http::HttpTimings t, const std::string &name, timespec stamp) { - _doh_result_cb(http_status, rcode, parse_ok, t, name, stamp); + [this](uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, visor::http::HttpTimings t, const std::string &name, timespec stamp) { + _doh_result_cb(http_status, rcode, parse_ok, cert_expiry_epoch, t, name, stamp); }); ++_id; probe->set_configs(_interval_msec, _timeout_msec, _packets_per_test, _packets_interval_msec, _packet_payload_size); diff --git a/src/inputs/netprobe/NetProbeInputStream.h b/src/inputs/netprobe/NetProbeInputStream.h index 62c24d7c8..f1d9f0d09 100644 --- a/src/inputs/netprobe/NetProbeInputStream.h +++ b/src/inputs/netprobe/NetProbeInputStream.h @@ -76,8 +76,8 @@ class NetProbeInputStream : public visor::InputStream void _send_cb(pcpp::Packet &, TestType, const std::string &, timespec); void _recv_cb(pcpp::Packet &, TestType, const std::string &, timespec); void _fail_cb(ErrorType, TestType, const std::string &); - void _http_result_cb(uint16_t status, visor::http::HttpTimings t, const std::string &name, timespec stamp); - void _doh_result_cb(uint16_t http_status, uint8_t rcode, bool parse_ok, visor::http::HttpTimings t, const std::string &name, timespec stamp); + void _http_result_cb(visor::http::HttpSample sample, const std::string &name, timespec stamp); + void _doh_result_cb(uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, visor::http::HttpTimings t, const std::string &name, timespec stamp); public: NetProbeInputStream(const std::string &name); @@ -124,14 +124,14 @@ class NetProbeInputEventProxy : public visor::InputEventProxy probe_fail_signal(e, t, n); } - void probe_http_result_cb(uint16_t status, visor::http::HttpTimings t, const std::string &n, timespec s) + void probe_http_result_cb(visor::http::HttpSample sample, const std::string &n, timespec s) { - probe_http_result_signal(status, t, n, s); + probe_http_result_signal(sample, n, s); } - void probe_doh_result_cb(uint16_t http_status, uint8_t rcode, bool parse_ok, visor::http::HttpTimings t, const std::string &n, timespec s) + void probe_doh_result_cb(uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, visor::http::HttpTimings t, const std::string &n, timespec s) { - probe_doh_result_signal(http_status, rcode, parse_ok, t, n, s); + probe_doh_result_signal(http_status, rcode, parse_ok, cert_expiry_epoch, t, n, s); } // handler functionality @@ -140,8 +140,8 @@ class NetProbeInputEventProxy : public visor::InputEventProxy mutable sigslot::signal probe_send_signal; mutable sigslot::signal probe_recv_signal; mutable sigslot::signal probe_fail_signal; - mutable sigslot::signal probe_http_result_signal; - mutable sigslot::signal probe_doh_result_signal; + mutable sigslot::signal probe_http_result_signal; + mutable sigslot::signal probe_doh_result_signal; }; } From fd0af343065557f75a2b10a508ff85fbbd978c15 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:46:43 -0300 Subject: [PATCH 04/14] =?UTF-8?q?feat(netprobe):=20v2=20config=20=E2=80=94?= =?UTF-8?q?=20status/body=20checks,=20request=20body,=20per-target=20heade?= =?UTF-8?q?rs,=20proxy,=20tls,=20UA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/inputs/netprobe/DohProbe.cpp | 19 +- src/inputs/netprobe/DohProbe.h | 8 +- src/inputs/netprobe/HttpProbe.cpp | 35 ++- src/inputs/netprobe/HttpProbe.h | 14 +- src/inputs/netprobe/HttpProbeOptions.h | 24 ++ src/inputs/netprobe/NetProbeInputStream.cpp | 215 +++++++++++++++- src/inputs/netprobe/NetProbeInputStream.h | 15 +- src/inputs/netprobe/test_netprobe.cpp | 256 +++++++++++++++++++- 8 files changed, 574 insertions(+), 12 deletions(-) create mode 100644 src/inputs/netprobe/HttpProbeOptions.h diff --git a/src/inputs/netprobe/DohProbe.cpp b/src/inputs/netprobe/DohProbe.cpp index ca1e7adfb..697b4ddff 100644 --- a/src/inputs/netprobe/DohProbe.cpp +++ b/src/inputs/netprobe/DohProbe.cpp @@ -140,12 +140,20 @@ bool DohProbe::start(std::shared_ptr io_loop) req.body = _query_wire; req.headers = {"Content-Type: application/dns-message", "Accept: application/dns-message"}; } + req.proxy = _opts.proxy; + req.ca_file = _opts.ca_file; + req.cert_file = _opts.cert_file; + req.key_file = _opts.key_file; + req.user_agent = _opts.user_agent; + req.verify_tls = _opts.tls_verify; + req.collect_cert_info = true; const std::string name = _name; const std::string qname = _wire_qname; // "" for the root; matches what pcpp getName() returns const uint16_t qtype_code = _qtype_code; auto doh_result = _doh_result; auto fail = _fail; - _client->request(req, [doh_result, fail, name, qname, qtype_code](const visor::http::HttpResult &r) { + auto cert_cache = _cert_cache; + _client->request(req, [doh_result, fail, name, qname, qtype_code, cert_cache](const visor::http::HttpResult &r) { timespec stamp; std::timespec_get(&stamp, TIME_UTC); auto logger = spdlog::get("visor"); @@ -199,7 +207,14 @@ bool DohProbe::start(std::shared_ptr io_loop) } else if (logger) { logger->debug("netprobe doh[{}]: response too short for a DNS message ({} bytes)", name, r.response_body.size()); } - doh_result(static_cast(r.status_code), rcode, parse_ok, r.cert_expiry_epoch, r.timings, name, stamp); + // Same cert-expiry cache contract as HttpProbe: CERTINFO is only populated on + // transfers that perform a TLS handshake, so cache the last known expiry per + // target and have every sample carry it. + if (r.cert_expiry_epoch != 0) { + *cert_cache = r.cert_expiry_epoch; + } + uint64_t cert_expiry_epoch = (r.cert_expiry_epoch != 0) ? r.cert_expiry_epoch : *cert_cache; + doh_result(static_cast(r.status_code), rcode, parse_ok, cert_expiry_epoch, r.timings, name, stamp); } else { if (logger) { logger->debug("netprobe doh[{}]: transport error: {} (curl code {})", name, r.error_msg, r.curl_code); diff --git a/src/inputs/netprobe/DohProbe.h b/src/inputs/netprobe/DohProbe.h index 33e0c3bc3..ac9c2a601 100644 --- a/src/inputs/netprobe/DohProbe.h +++ b/src/inputs/netprobe/DohProbe.h @@ -4,8 +4,10 @@ #pragma once #include "HttpClient.h" +#include "HttpProbeOptions.h" #include "NetProbe.h" #include +#include #include namespace visor::input::netprobe { @@ -20,24 +22,28 @@ class DohProbe final : public NetProbe std::string _qname; std::string _qtype; // e.g. "A" std::shared_ptr _client; + HttpProbeOptions _opts; // uses only proxy/tls/user_agent + collect_cert_info DohResultCallback _doh_result; std::shared_ptr _interval_timer; std::string _query_wire; // pre-built DNS query (wire format), built in start() std::string _get_url; // pre-built URL with ?dns= for GET uint16_t _qtype_code{0}; // numeric DNS qtype (from QTypeNumbers), for response question validation std::string _wire_qname; // qname as pcpp encodes/decodes it: "" for the root ("."), else _qname + // Same cert-expiry cache contract as HttpProbe (see there for the rationale). + std::shared_ptr _cert_cache{std::make_shared(0)}; bool _init{false}; public: DohProbe(uint16_t id, const std::string &name, std::string url, std::string method, std::string qname, std::string qtype, - std::shared_ptr client, DohResultCallback doh_result) + std::shared_ptr client, HttpProbeOptions opts, DohResultCallback doh_result) : NetProbe(id, name, pcpp::IPAddress(), std::string()) , _url(std::move(url)) , _method(std::move(method)) , _qname(std::move(qname)) , _qtype(std::move(qtype)) , _client(std::move(client)) + , _opts(std::move(opts)) , _doh_result(std::move(doh_result)) {} ~DohProbe() = default; bool start(std::shared_ptr io_loop) override; diff --git a/src/inputs/netprobe/HttpProbe.cpp b/src/inputs/netprobe/HttpProbe.cpp index d32d0bb02..6182d3c26 100644 --- a/src/inputs/netprobe/HttpProbe.cpp +++ b/src/inputs/netprobe/HttpProbe.cpp @@ -28,18 +28,47 @@ bool HttpProbe::start(std::shared_ptr io_loop) req.url = _url; req.method = _method; req.timeout_ms = _config.timeout_msec; + req.body = _opts.request_body; + req.headers = _headers; + req.proxy = _opts.proxy; + req.ca_file = _opts.ca_file; + req.cert_file = _opts.cert_file; + req.key_file = _opts.key_file; + req.user_agent = _opts.user_agent; + req.verify_tls = _opts.tls_verify; + req.collect_cert_info = true; + req.capture_response = _opts.body_check.configured(); const std::string name = _name; auto http_result = _http_result; auto fail = _fail; - _client->request(req, [http_result, fail, name](const visor::http::HttpResult &r) { + auto opts = _opts; // copyable (regex copies are fine at probe frequency); no `this` in completion lambda + auto cert_cache = _cert_cache; + _client->request(req, [http_result, fail, name, opts, cert_cache](const visor::http::HttpResult &r) { timespec stamp; std::timespec_get(&stamp, TIME_UTC); if (r.transport_ok) { visor::http::HttpSample s; s.status = static_cast(r.status_code); - s.status_ok = (s.status >= 200 && s.status < 400); // v1 default; Task 4 replaces with checks + bool status_ok; + if (opts.failure_status.matches(s.status)) { + status_ok = false; + } else if (!opts.expected_status.empty()) { + status_ok = opts.expected_status.matches(s.status); + } else { + status_ok = (s.status >= 200 && s.status < 400); + } + s.status_ok = status_ok; s.content_check = 0; - s.cert_expiry_epoch = r.cert_expiry_epoch; + if (status_ok && opts.body_check.configured()) { + s.content_check = opts.body_check.matches(r.response_body) ? 1 : 2; + } + // CERTINFO is only filled on transfers that performed a TLS handshake; reused + // pooled connections report nothing. Cache the last known expiry per target so + // EVERY sample carries it and the metric doesn't flap with connection reuse. + if (r.cert_expiry_epoch != 0) { + *cert_cache = r.cert_expiry_epoch; + } + s.cert_expiry_epoch = (r.cert_expiry_epoch != 0) ? r.cert_expiry_epoch : *cert_cache; s.response_size = r.response_size; s.timings = r.timings; http_result(s, name, stamp); diff --git a/src/inputs/netprobe/HttpProbe.h b/src/inputs/netprobe/HttpProbe.h index db0b3a841..96cda61b9 100644 --- a/src/inputs/netprobe/HttpProbe.h +++ b/src/inputs/netprobe/HttpProbe.h @@ -4,8 +4,10 @@ #pragma once #include "HttpClient.h" +#include "HttpProbeOptions.h" #include "NetProbe.h" #include +#include #include namespace visor::input::netprobe { @@ -17,17 +19,27 @@ class HttpProbe final : public NetProbe std::string _url; std::string _method; std::shared_ptr _client; + HttpProbeOptions _opts; + std::vector _headers; HttpResultCallback _http_result; std::shared_ptr _interval_timer; + // CERTINFO is only populated on transfers that perform a TLS handshake; pooled-connection + // reuse reports nothing. Cache the last known expiry so every sample carries it. shared_ptr so + // the completion lambda can capture it BY VALUE (no `this` in completion lambdas — v1 contract) + // while still sharing the same cell across ticks. + std::shared_ptr _cert_cache{std::make_shared(0)}; bool _init{false}; public: HttpProbe(uint16_t id, const std::string &name, std::string url, std::string method, - std::shared_ptr client, HttpResultCallback http_result) + std::shared_ptr client, HttpProbeOptions opts, std::vector headers, + HttpResultCallback http_result) : NetProbe(id, name, pcpp::IPAddress(), std::string()) , _url(std::move(url)) , _method(std::move(method)) , _client(std::move(client)) + , _opts(std::move(opts)) + , _headers(std::move(headers)) , _http_result(std::move(http_result)) {} ~HttpProbe() = default; bool start(std::shared_ptr io_loop) override; diff --git a/src/inputs/netprobe/HttpProbeOptions.h b/src/inputs/netprobe/HttpProbeOptions.h new file mode 100644 index 000000000..cb2f86bb3 --- /dev/null +++ b/src/inputs/netprobe/HttpProbeOptions.h @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +#pragma once +#include "HttpCheck.h" +#include + +namespace visor::input::netprobe { + +// Shared http/doh transport + check options, parsed/validated once by NetProbeInputStream and +// handed to both probes. Lives in its own header so HttpProbe.h/DohProbe.h don't need to include +// NetProbeInputStream.h (which would create an include-surface cycle). +struct HttpProbeOptions { + visor::http::StatusMatcher expected_status; // empty => default 2xx/3xx + visor::http::StatusMatcher failure_status; // empty => none + visor::http::BodyCheck body_check; // http only + std::string request_body; // http only + std::string proxy; + bool tls_verify{true}; + std::string ca_file, cert_file, key_file; + std::string user_agent; // "pktvisor/" VISOR_VERSION_NUM +}; +} diff --git a/src/inputs/netprobe/NetProbeInputStream.cpp b/src/inputs/netprobe/NetProbeInputStream.cpp index 5e0779620..db1cfed5d 100644 --- a/src/inputs/netprobe/NetProbeInputStream.cpp +++ b/src/inputs/netprobe/NetProbeInputStream.cpp @@ -11,6 +11,9 @@ #include "TcpProbe.h" #include "ThreadName.h" #include "dns.h" +#include "visor_config.h" +#include +#include #include #ifdef __GNUC__ #pragma GCC diagnostic push @@ -30,6 +33,59 @@ namespace visor::input::netprobe { +namespace { + +std::string test_type_name(TestType t) +{ + switch (t) { + case TestType::Ping: + return "ping"; + case TestType::HTTP: + return "http"; + case TestType::UDP: + return "udp"; + case TestType::TCP: + return "tcp"; + case TestType::DOH: + return "doh"; + } + return "unknown"; +} + +// Join a per-target "headers" sub-Configurable entry into its "name: value" string. The YAML +// loader stores scalars typed (uint64_t/bool/string), so a header value like `12345` or `true` +// is NOT a std::string in the Configurable and config_get() throws on it. Configurable +// exposes no cheaper type-dispatch accessor, so fall back through the scalar types it can hold. +std::string header_value_to_string(const visor::Configurable &headers, const std::string &name) +{ + try { + return headers.config_get(name); + } catch (const visor::ConfigException &) { + } + try { + return std::to_string(headers.config_get(name)); + } catch (const visor::ConfigException &) { + } + try { + return headers.config_get(name) ? "true" : "false"; + } catch (const visor::ConfigException &) { + } + throw NetProbeException(fmt::format("netprobe: header '{}' has an unsupported value type", name)); +} + +// Trim leading/trailing ASCII whitespace. +std::string trim(const std::string &s) +{ + auto first = s.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + return {}; + } + auto last = s.find_last_not_of(" \t\r\n"); + return s.substr(first, last - first + 1); +} + +} + uint16_t NetProbeInputStream::_id = 1; NetProbeInputStream::NetProbeInputStream(const std::string &name) @@ -129,6 +185,53 @@ void NetProbeInputStream::start() } } + // ---- v2: checks (http only) ---- + if (config_exists("expected_status")) { + try { + _http_opts.expected_status = visor::http::StatusMatcher::parse(config_get("expected_status")); + } catch (const std::invalid_argument &e) { + throw NetProbeException(fmt::format("netprobe: expected_status: {}", e.what())); + } + } + if (config_exists("failure_status")) { + try { + _http_opts.failure_status = visor::http::StatusMatcher::parse(config_get("failure_status")); + } catch (const std::invalid_argument &e) { + throw NetProbeException(fmt::format("netprobe: failure_status: {}", e.what())); + } + } + { + std::string sub = config_exists("expected_body") ? config_get("expected_body") : ""; + std::string rx = config_exists("expected_body_regex") ? config_get("expected_body_regex") : ""; + try { + _http_opts.body_check = visor::http::BodyCheck::compile(sub, rx); + } catch (const std::invalid_argument &e) { + throw NetProbeException(fmt::format("netprobe: {}", e.what())); + } + } + if (config_exists("body")) { + _http_opts.request_body = config_get("body"); + } + if (config_exists("proxy")) { + _http_opts.proxy = config_get("proxy"); + } + if (config_exists("tls")) { + auto tls = config_get>("tls"); + if (tls->config_exists("verify")) { + _http_opts.tls_verify = tls->config_get("verify"); + } + if (tls->config_exists("ca_file")) { + _http_opts.ca_file = tls->config_get("ca_file"); + } + if (tls->config_exists("cert_file")) { + _http_opts.cert_file = tls->config_get("cert_file"); + } + if (tls->config_exists("key_file")) { + _http_opts.key_file = tls->config_get("key_file"); + } + } + _http_opts.user_agent = std::string("pktvisor/") + VISOR_VERSION_NUM; + if (!config_exists("targets")) { throw NetProbeException("no targets specified"); } else { @@ -145,6 +248,18 @@ void NetProbeInputStream::start() throw NetProbeException(fmt::format("target '{}' {}", key, *err)); } _http_targets[key] = url; + if (config->config_exists("headers")) { + auto headers = config->config_get>("headers"); + auto hkeys = headers->get_all_keys(); + std::vector joined; + std::vector names; + for (const auto &hkey : hkeys) { + joined.push_back(fmt::format("{}: {}", hkey, header_value_to_string(*headers, hkey))); + names.push_back(hkey); + } + _http_target_headers[key] = std::move(joined); + _http_target_header_names[key] = std::move(names); + } continue; } if (_type == TestType::DOH) { @@ -152,6 +267,9 @@ void NetProbeInputStream::start() if (auto err = visor::http::validate_http_url(url)) { throw NetProbeException(fmt::format("target '{}' {}", key, *err)); } + if (config->config_exists("headers")) { + throw NetProbeException("per-target 'headers' is not supported for test_type 'doh'"); + } _doh_targets[key] = url; continue; } @@ -221,6 +339,66 @@ void NetProbeInputStream::start() } } + // ---- v2: scope/consistency validation ---- + // http-only keys: check each individually so the thrown message names the offending key. + { + static const std::vector http_only_keys = { + "expected_status", "failure_status", "expected_body", "expected_body_regex", "body"}; + if (_type != TestType::HTTP) { + for (const auto &key : http_only_keys) { + if (config_exists(key)) { + throw NetProbeException(fmt::format("'{}' is not supported for test_type '{}'", key, test_type_name(_type))); + } + } + } + } + // proxy/tls are shared http+doh transport options; not meaningful for ping/tcp/udp. + if (_type != TestType::HTTP && _type != TestType::DOH) { + if (config_exists("proxy")) { + throw NetProbeException("'proxy' is only supported for test_type 'http' or 'doh'"); + } + if (config_exists("tls")) { + throw NetProbeException("'tls' is only supported for test_type 'http' or 'doh'"); + } + } + // request body only makes sense paired with a method that carries one. + if (_type == TestType::HTTP && !_http_opts.request_body.empty()) { + if (_http_method != "POST" && _http_method != "PUT" && _http_method != "PATCH") { + throw NetProbeException("'body' requires http_method POST, PUT, or PATCH"); + } + } + // client cert + key must be configured together (curl requires both or neither). + if (_http_opts.cert_file.empty() != _http_opts.key_file.empty()) { + throw NetProbeException("tls.cert_file and tls.key_file must be set together"); + } + // configured TLS files must exist. std::filesystem (not access()/) so this stays + // portable to the MSVC/win64 netprobe build. + for (const auto &[label, path] : std::vector>{ + {"ca_file", _http_opts.ca_file}, {"cert_file", _http_opts.cert_file}, {"key_file", _http_opts.key_file}}) { + if (path.empty()) { + continue; + } + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + throw NetProbeException(fmt::format("netprobe: tls.{} '{}' does not exist", label, path)); + } + } + // proxy, when set, must be a usable value: non-empty after trim and free of control characters + // (which could otherwise smuggle protocol-confusing bytes). curl remains the authoritative + // validator of the proxy URL itself at probe time. Never echo the value — it can embed credentials. + if (!_http_opts.proxy.empty()) { + bool has_cntrl = false; + for (unsigned char ch : _http_opts.proxy) { + if (std::iscntrl(ch)) { + has_cntrl = true; + break; + } + } + if (has_cntrl || trim(_http_opts.proxy).empty()) { + throw NetProbeException("netprobe: 'proxy' value is invalid"); + } + } + _create_netprobe_loop(); _running = true; @@ -363,7 +541,11 @@ void NetProbeInputStream::_create_netprobe_loop() } for (const auto &[key, url] : _http_targets) { - auto probe = std::make_unique(_id, key, url, _http_method, _http_client, + std::vector headers; + if (auto it = _http_target_headers.find(key); it != _http_target_headers.end()) { + headers = it->second; + } + auto probe = std::make_unique(_id, key, url, _http_method, _http_client, _http_opts, headers, [this](visor::http::HttpSample sample, const std::string &name, timespec stamp) { _http_result_cb(sample, name, stamp); }); ++_id; probe->set_configs(_interval_msec, _timeout_msec, _packets_per_test, _packets_interval_msec, _packet_payload_size); @@ -375,7 +557,7 @@ void NetProbeInputStream::_create_netprobe_loop() } for (const auto &[key, url] : _doh_targets) { - auto probe = std::make_unique(_id, key, url, _doh_method, _doh_qname, _doh_qtype, _http_client, + auto probe = std::make_unique(_id, key, url, _doh_method, _doh_qname, _doh_qtype, _http_client, _http_opts, [this](uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, visor::http::HttpTimings t, const std::string &name, timespec stamp) { _doh_result_cb(http_status, rcode, parse_ok, cert_expiry_epoch, t, name, stamp); }); @@ -429,6 +611,35 @@ void NetProbeInputStream::stop() void NetProbeInputStream::info_json(json &j) const { common_info_json(j); + // common_info_json() echoes the module's RAW config verbatim at j["module"]["config"] (via + // Configurable::config_json). Scrub every value that can carry a secret before this JSON goes + // anywhere: proxy URLs can embed credentials, and header/body/expected_body(_regex) values can + // be anything the operator configured (Authorization headers, tokens in a probe body, etc.). + // Header/proxy/body values must NEVER appear in info_json — only header/target NAMES are safe. + if (j.contains("module") && j["module"].contains("config")) { + auto &cfg = j["module"]["config"]; + for (const char *key : {"proxy", "body", "expected_body", "expected_body_regex"}) { + if (cfg.contains(key)) { + cfg[key] = ""; + } + } + if (cfg.contains("targets") && cfg["targets"].is_object()) { + for (auto &el : cfg["targets"].items()) { + auto &tgt_val = el.value(); + if (tgt_val.is_object() && tgt_val.contains("headers") && tgt_val["headers"].is_object()) { + for (auto &hel : tgt_val["headers"].items()) { + hel.value() = ""; + } + } + } + // Per-target header NAMES (never values) are safe to surface and useful for debugging. + for (const auto &[tgt_name, names] : _http_target_header_names) { + if (cfg["targets"].contains(tgt_name)) { + cfg["targets"][tgt_name]["header_names"] = names; + } + } + } + } j[schema_key()]["current_targets_total"] = _dns_list.size() + _ip_list.size() + _http_targets.size() + _doh_targets.size(); // Report ping socket usage only for ping streams. Derive the probe count from _probes — a stable // per-stream member after start() — rather than a thread_local counter, which info_json (invoked diff --git a/src/inputs/netprobe/NetProbeInputStream.h b/src/inputs/netprobe/NetProbeInputStream.h index f1d9f0d09..4e4cbff08 100644 --- a/src/inputs/netprobe/NetProbeInputStream.h +++ b/src/inputs/netprobe/NetProbeInputStream.h @@ -4,6 +4,7 @@ #pragma once +#include "HttpProbeOptions.h" #include "HttpTypes.h" #include "InputStream.h" #include "NetProbe.h" @@ -39,6 +40,11 @@ class NetProbeInputStream : public visor::InputStream std::map _dns_list; std::map _http_targets; std::string _http_method{"GET"}; + // per-target "Key: Value" header entries, keyed like _http_targets; keep header VALUES + // (used to build the actual request) separate from just the NAMES (safe to echo in info_json). + std::map> _http_target_headers; + std::map> _http_target_header_names; + HttpProbeOptions _http_opts; std::map _doh_targets; std::string _doh_qname; std::string _doh_qtype{"A"}; @@ -70,7 +76,14 @@ class NetProbeInputStream : public visor::InputStream "targets", "http_method", "qname", - "qtype"}; + "qtype", + "expected_status", + "failure_status", + "expected_body", + "expected_body_regex", + "body", + "proxy", + "tls"}; void _create_netprobe_loop(); void _send_cb(pcpp::Packet &, TestType, const std::string &, timespec); diff --git a/src/inputs/netprobe/test_netprobe.cpp b/src/inputs/netprobe/test_netprobe.cpp index 8f6625a99..727835e4c 100644 --- a/src/inputs/netprobe/test_netprobe.cpp +++ b/src/inputs/netprobe/test_netprobe.cpp @@ -4,7 +4,9 @@ #include #include +#include #include +#include #include #include #include @@ -124,7 +126,7 @@ TEST_CASE("Netprobe invalid config", "[netprobe][config]") NetProbeInputStream stream{"net-probe-test"}; stream.config_set("invalid_config", true); - CHECK_THROWS_WITH(stream.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype"); + CHECK_THROWS_WITH(stream.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype, expected_status, failure_status, expected_body, expected_body_regex, body, proxy, tls"); } TEST_CASE("NetProbe ip_version config", "[netprobe][config][ipv6]") @@ -159,7 +161,7 @@ TEST_CASE("NetProbe ip_version config", "[netprobe][config][ipv6]") SECTION("top-level valid-keys string unchanged") { NetProbeInputStream s{"net-probe-test"}; s.config_set("invalid_config", true); - CHECK_THROWS_WITH(s.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype"); + CHECK_THROWS_WITH(s.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype, expected_status, failure_status, expected_body, expected_body_regex, body, proxy, tls"); } } @@ -264,6 +266,189 @@ TEST_CASE("NetProbe http/doh config: invalid target URL rejected", "[netprobe][c CHECK_THROWS_WITH(stream.start(), "target 'bad' is not a valid http(s) URL: 'ftp://example.com/x'"); } +TEST_CASE("NetProbe v2 config: expected_status grammar errors bubble the bad entry", "[netprobe][config][http]") +{ + NetProbeInputStream stream{"net-probe-test-status-bad"}; + stream.config_set("test_type", "http"); + stream.config_set("expected_status", {"2x"}); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://example.com/")); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + CHECK_THROWS_WITH(stream.start(), Catch::Matchers::ContainsSubstring("invalid status entry '2x'")); +} + +TEST_CASE("NetProbe v2 config: proxy with an embedded control character is rejected without leaking the value", "[netprobe][config][http]") +{ + NetProbeInputStream stream{"net-probe-test-proxy-bad"}; + stream.config_set("test_type", "http"); + stream.config_set("proxy", std::string("bad\nvalue")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://example.com/")); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + CHECK_THROWS_WITH(stream.start(), "netprobe: 'proxy' value is invalid"); + // A second, independent check that the raw value never appears in the exception message. + try { + stream.start(); + FAIL("expected NetProbeException"); + } catch (const std::exception &e) { + CHECK_THAT(std::string(e.what()), !Catch::Matchers::ContainsSubstring("bad\nvalue")); + } +} + +TEST_CASE("NetProbe v2 config: unclosed expected_body_regex is rejected without quoting the pattern", "[netprobe][config][http]") +{ + NetProbeInputStream stream{"net-probe-test-regex-bad"}; + stream.config_set("test_type", "http"); + stream.config_set("expected_body_regex", std::string("(unclosed")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://example.com/")); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + CHECK_THROWS_WITH(stream.start(), + Catch::Matchers::ContainsSubstring("expected_body_regex") && !Catch::Matchers::ContainsSubstring("(unclosed")); +} + +TEST_CASE("NetProbe v2 config: body requires a method that carries one", "[netprobe][config][http]") +{ + NetProbeInputStream stream{"net-probe-test-body-get"}; + stream.config_set("test_type", "http"); + stream.config_set("http_method", std::string("GET")); + stream.config_set("body", std::string("payload")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://example.com/")); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + CHECK_THROWS_WITH(stream.start(), "'body' requires http_method POST, PUT, or PATCH"); +} + +TEST_CASE("NetProbe v2 config: http-only keys are rejected on a doh stream", "[netprobe][config][doh]") +{ + auto make_doh_stream = [](const std::string &name) { + auto s = std::make_unique(name); + s->config_set("test_type", "doh"); + s->config_set("qname", std::string("example.com")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://1.1.1.1/dns-query")); + targets->config_set>("t", target); + s->config_set>("targets", targets); + return s; + }; + + SECTION("expected_body") + { + auto s = make_doh_stream("doh-expected-body"); + s->config_set("expected_body", std::string("x")); + CHECK_THROWS_WITH(s->start(), "'expected_body' is not supported for test_type 'doh'"); + } + SECTION("expected_body_regex") + { + auto s = make_doh_stream("doh-expected-body-regex"); + s->config_set("expected_body_regex", std::string("x")); + CHECK_THROWS_WITH(s->start(), "'expected_body_regex' is not supported for test_type 'doh'"); + } + SECTION("body") + { + auto s = make_doh_stream("doh-body"); + s->config_set("body", std::string("x")); + CHECK_THROWS_WITH(s->start(), "'body' is not supported for test_type 'doh'"); + } + SECTION("expected_status") + { + auto s = make_doh_stream("doh-expected-status"); + s->config_set("expected_status", {"200"}); + CHECK_THROWS_WITH(s->start(), "'expected_status' is not supported for test_type 'doh'"); + } + SECTION("failure_status") + { + auto s = make_doh_stream("doh-failure-status"); + s->config_set("failure_status", {"500"}); + CHECK_THROWS_WITH(s->start(), "'failure_status' is not supported for test_type 'doh'"); + } +} + +TEST_CASE("NetProbe v2 config: per-target headers are not supported for doh", "[netprobe][config][doh]") +{ + NetProbeInputStream stream{"net-probe-test-doh-headers"}; + stream.config_set("test_type", "doh"); + stream.config_set("qname", std::string("example.com")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://1.1.1.1/dns-query")); + auto headers = std::make_shared(); + headers->config_set("Authorization", std::string("Bearer secret")); + target->config_set>("headers", headers); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + CHECK_THROWS_WITH(stream.start(), "per-target 'headers' is not supported for test_type 'doh'"); +} + +TEST_CASE("NetProbe v2 config: tls.cert_file requires tls.key_file", "[netprobe][config][http]") +{ + NetProbeInputStream stream{"net-probe-test-tls-xor"}; + stream.config_set("test_type", "http"); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://example.com/")); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + auto tls = std::make_shared(); + tls->config_set("cert_file", std::string("/tmp/does_not_matter.pem")); + stream.config_set>("tls", tls); + CHECK_THROWS_WITH(stream.start(), "tls.cert_file and tls.key_file must be set together"); +} + +TEST_CASE("NetProbe v2 config: tls.ca_file must exist", "[netprobe][config][http]") +{ + NetProbeInputStream stream{"net-probe-test-tls-ca-missing"}; + stream.config_set("test_type", "http"); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("https://example.com/")); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + auto tls = std::make_shared(); + tls->config_set("ca_file", std::string("/nonexistent/ca.pem")); + stream.config_set>("tls", tls); + CHECK_THROWS_WITH(stream.start(), Catch::Matchers::ContainsSubstring("/nonexistent/ca.pem")); +} + +TEST_CASE("NetProbe v2 config: proxy and tls are not supported for tcp", "[netprobe][config][tcp]") +{ + auto make_tcp_stream = [](const std::string &name) { + auto s = std::make_unique(name); + s->config_set("test_type", "tcp"); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", std::string("example.com")); + target->config_set("port", 80); + targets->config_set>("t", target); + s->config_set>("targets", targets); + return s; + }; + + SECTION("proxy") + { + auto s = make_tcp_stream("tcp-proxy"); + s->config_set("proxy", std::string("http://x")); + CHECK_THROWS_WITH(s->start(), "'proxy' is only supported for test_type 'http' or 'doh'"); + } + SECTION("tls") + { + auto s = make_tcp_stream("tcp-tls"); + auto tls = std::make_shared(); + tls->config_set("verify", false); + s->config_set>("tls", tls); + CHECK_THROWS_WITH(s->start(), "'tls' is only supported for test_type 'http' or 'doh'"); + } +} + TEST_CASE("ICMPv6 reply carrier survives the fan-out Packet deep-copy", "[netprobe][ipv6]") { // Wire bytes of an ICMPv6 echo REPLY: type=129, code=0, checksum=0, id=0xBEEF, seq=0x0102 (network order). @@ -368,6 +553,73 @@ TEST_CASE("NetProbe HTTP e2e: success path records attempt, success, and 200 in CHECK(found_200); } +TEST_CASE("NetProbe v2 info_json: proxy/body/expected_body(_regex)/header values are scrubbed", "[netprobe][http][config]") +{ + // common_info_json() echoes the raw module config verbatim; without scrubbing this would leak + // the Authorization header, the numeric header, the proxy URL, and both body-check patterns. + httplib::Server svr; + svr.Post("/submit", [](const httplib::Request &, httplib::Response &res) { + res.set_content("ok", "text/plain"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/submit"; + + NetProbeInputStream stream{"netprobe-http-redact"}; + stream.config_set("test_type", "http"); + stream.config_set("http_method", std::string("POST")); + stream.config_set("interval_msec", 500); + stream.config_set("timeout_msec", 400); + stream.config_set("body", std::string("super-secret-payload")); + stream.config_set("expected_body", std::string("super-secret-expected")); + stream.config_set("expected_body_regex", std::string("^ok-[0-9]+$")); + stream.config_set("proxy", std::string("http://proxy.invalid.example:3128")); + + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + auto headers = std::make_shared(); + headers->config_set("Authorization", std::string("Bearer super-secret-token")); + headers->config_set("X-Request-Id", 424242); + target->config_set>("headers", headers); + targets->config_set>("t", target); + stream.config_set>("targets", targets); + + stream.start(); + std::this_thread::sleep_for(50ms); + stream.stop(); + + nlohmann::json j; + stream.info_json(j); + auto &cfg = j["module"]["config"]; + + CHECK(cfg["proxy"] == ""); + CHECK(cfg["body"] == ""); + CHECK(cfg["expected_body"] == ""); + CHECK(cfg["expected_body_regex"] == ""); + REQUIRE(cfg["targets"]["t"].contains("headers")); + CHECK(cfg["targets"]["t"]["headers"]["Authorization"] == ""); + CHECK(cfg["targets"]["t"]["headers"]["X-Request-Id"] == ""); + REQUIRE(cfg["targets"]["t"].contains("header_names")); + auto names = cfg["targets"]["t"]["header_names"].get>(); + CHECK(std::find(names.begin(), names.end(), "Authorization") != names.end()); + CHECK(std::find(names.begin(), names.end(), "X-Request-Id") != names.end()); + + // Belt-and-suspenders: none of the secret literals may survive anywhere in the serialized JSON. + std::string dump = j.dump(); + CHECK(dump.find("super-secret-token") == std::string::npos); + CHECK(dump.find("super-secret-payload") == std::string::npos); + CHECK(dump.find("super-secret-expected") == std::string::npos); + CHECK(dump.find("proxy.invalid.example") == std::string::npos); + CHECK(dump.find("424242") == std::string::npos); + CHECK(dump.find("^ok-[0-9]+$") == std::string::npos); + CHECK(dump.find("") != std::string::npos); +} + TEST_CASE("NetProbe HTTP e2e: stop while request in flight does not crash or hang", "[netprobe][http][e2e]") { // The slow handler sleeps 500ms — longer than our start/stop window. From 43911a672d7733e88cf3cec3bf7f4e646f85b1cd Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:21:17 -0300 Subject: [PATCH 05/14] feat(netprobe): content_failures, tls_cert_expiry_epoch_sec, response_size_bytes metrics --- .../netprobe/NetProbeStreamHandler.cpp | 45 ++++- src/handlers/netprobe/NetProbeStreamHandler.h | 9 +- src/handlers/netprobe/test_net_probe.cpp | 181 ++++++++++++++++++ 3 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/handlers/netprobe/NetProbeStreamHandler.cpp b/src/handlers/netprobe/NetProbeStreamHandler.cpp index 580033828..3616b6eb2 100644 --- a/src/handlers/netprobe/NetProbeStreamHandler.cpp +++ b/src/handlers/netprobe/NetProbeStreamHandler.cpp @@ -147,15 +147,22 @@ void NetProbeMetricsBucket::specialized_merge(const AbstractMetricsBucket &o, Me _targets_metrics[targetId]->dns_failures += target.second->dns_failures; _targets_metrics[targetId]->timed_out += target.second->timed_out; _targets_metrics[targetId]->http_status_failures += target.second->http_status_failures; + _targets_metrics[targetId]->content_failures += target.second->content_failures; _targets_metrics[targetId]->top_status_codes.merge(target.second->top_status_codes); _targets_metrics[targetId]->dns_response_failures += target.second->dns_response_failures; _targets_metrics[targetId]->top_rcodes.merge(target.second->top_rcodes); + // Merged windows lose per-sample ordering, so "latest wins" is meaningless here; + // take the max instead, which biases toward the most-recently-renewed certificate. + if (target.second->tls_cert_expiry_epoch > _targets_metrics[targetId]->tls_cert_expiry_epoch) { + _targets_metrics[targetId]->tls_cert_expiry_epoch = target.second->tls_cert_expiry_epoch; + } } if (group_enabled(group::NetProbeMetrics::Histograms)) { _targets_metrics[targetId]->h_time_us.merge(target.second->h_time_us); } if (group_enabled(group::NetProbeMetrics::Quantiles)) { _targets_metrics[targetId]->q_time_us.merge(target.second->q_time_us, agg_operator); + _targets_metrics[targetId]->q_response_size.merge(target.second->q_response_size, agg_operator); } if (group_enabled(group::NetProbeMetrics::HttpResponsePhases)) { _targets_metrics[targetId]->q_dns_us.merge(target.second->q_dns_us, agg_operator); @@ -182,9 +189,15 @@ void NetProbeMetricsBucket::to_prometheus(PrometheusSerializer &ser, Metric::Lab target.second->dns_failures.to_prometheus(ser, target_labels); target.second->timed_out.to_prometheus(ser, target_labels); target.second->http_status_failures.to_prometheus(ser, target_labels); + target.second->content_failures.to_prometheus(ser, target_labels); target.second->top_status_codes.to_prometheus(ser, target_labels); target.second->dns_response_failures.to_prometheus(ser, target_labels); target.second->top_rcodes.to_prometheus(ser, target_labels); + if (target.second->tls_cert_expiry_epoch != 0) { + target.second->tls_cert_expiry.clear(); + target.second->tls_cert_expiry += target.second->tls_cert_expiry_epoch; + target.second->tls_cert_expiry.to_prometheus(ser, target_labels); + } } bool h_max_min{true}; @@ -220,6 +233,7 @@ void NetProbeMetricsBucket::to_prometheus(PrometheusSerializer &ser, Metric::Lab target.second->maximum.to_prometheus(ser, target_labels); } target.second->q_time_us.to_prometheus(ser, target_labels); + target.second->q_response_size.to_prometheus(ser, target_labels); } catch (const std::exception &) { } } @@ -252,9 +266,15 @@ void NetProbeMetricsBucket::to_opentelemetry(metrics::v1::ScopeMetrics &scope, t target.second->dns_failures.to_opentelemetry(scope, start_ts, end_ts, target_labels); target.second->timed_out.to_opentelemetry(scope, start_ts, end_ts, target_labels); target.second->http_status_failures.to_opentelemetry(scope, start_ts, end_ts, target_labels); + target.second->content_failures.to_opentelemetry(scope, start_ts, end_ts, target_labels); target.second->top_status_codes.to_opentelemetry(scope, start_ts, end_ts, target_labels); target.second->dns_response_failures.to_opentelemetry(scope, start_ts, end_ts, target_labels); target.second->top_rcodes.to_opentelemetry(scope, start_ts, end_ts, target_labels); + if (target.second->tls_cert_expiry_epoch != 0) { + target.second->tls_cert_expiry.clear(); + target.second->tls_cert_expiry += target.second->tls_cert_expiry_epoch; + target.second->tls_cert_expiry.to_opentelemetry(scope, start_ts, end_ts, target_labels); + } } bool h_max_min{true}; @@ -290,6 +310,7 @@ void NetProbeMetricsBucket::to_opentelemetry(metrics::v1::ScopeMetrics &scope, t target.second->maximum.to_opentelemetry(scope, start_ts, end_ts, target_labels); } target.second->q_time_us.to_opentelemetry(scope, start_ts, end_ts, target_labels); + target.second->q_response_size.to_opentelemetry(scope, start_ts, end_ts, target_labels); } catch (const std::exception &) { } } @@ -321,9 +342,15 @@ void NetProbeMetricsBucket::to_json(json &j) const target.second->dns_failures.to_json(j["targets"][targetId]); target.second->timed_out.to_json(j["targets"][targetId]); target.second->http_status_failures.to_json(j["targets"][targetId]); + target.second->content_failures.to_json(j["targets"][targetId]); target.second->top_status_codes.to_json(j["targets"][targetId]); target.second->dns_response_failures.to_json(j["targets"][targetId]); target.second->top_rcodes.to_json(j["targets"][targetId]); + if (target.second->tls_cert_expiry_epoch != 0) { + target.second->tls_cert_expiry.clear(); + target.second->tls_cert_expiry += target.second->tls_cert_expiry_epoch; + target.second->tls_cert_expiry.to_json(j["targets"][targetId]); + } } bool h_max_min{true}; @@ -359,6 +386,7 @@ void NetProbeMetricsBucket::to_json(json &j) const target.second->maximum.to_json(j["targets"][targetId]); } target.second->q_time_us.to_json(j["targets"][targetId]); + target.second->q_response_size.to_json(j["targets"][targetId]); } catch (const std::exception &) { } } @@ -542,10 +570,15 @@ void NetProbeMetricsBucket::process_netprobe_http(bool deep, const visor::http:: // like new_transaction's successes++ (not gated on `deep`). if (group_enabled(group::NetProbeMetrics::Counters)) { t.top_status_codes.update(std::to_string(sample.status)); - if (sample.status_ok) { - ++t.successes; - } else { + if (!sample.status_ok) { ++t.http_status_failures; + } else if (sample.content_check == 2) { + ++t.content_failures; + } else { + ++t.successes; + } + if (sample.cert_expiry_epoch != 0) { + t.tls_cert_expiry_epoch = sample.cert_expiry_epoch; // latest non-zero wins } } @@ -556,6 +589,7 @@ void NetProbeMetricsBucket::process_netprobe_http(bool deep, const visor::http:: } if (deep && group_enabled(group::NetProbeMetrics::Quantiles)) { t.q_time_us.update(sample.timings.total_us); + t.q_response_size.update(sample.response_size); } if (deep && group_enabled(group::NetProbeMetrics::HttpResponsePhases)) { t.q_dns_us.update(sample.timings.dns_us); @@ -581,7 +615,7 @@ void NetProbeMetricsManager::process_netprobe_http_failure(ErrorType error, cons live_bucket()->process_failure(error, target); } -void NetProbeMetricsBucket::process_netprobe_doh(bool deep, uint16_t http_status, uint8_t rcode, bool parse_ok, [[maybe_unused]] uint64_t cert_expiry_epoch, const visor::http::HttpTimings &timings, const std::string &target) +void NetProbeMetricsBucket::process_netprobe_doh(bool deep, uint16_t http_status, uint8_t rcode, bool parse_ok, uint64_t cert_expiry_epoch, const visor::http::HttpTimings &timings, const std::string &target) { std::unique_lock lock(_mutex); @@ -591,6 +625,9 @@ void NetProbeMetricsBucket::process_netprobe_doh(bool deep, uint16_t http_status // DoH responses are HTTP responses too: record the HTTP status breakdown (like the HTTP // probe) in addition to the DNS rcode breakdown below. t.top_status_codes.update(std::to_string(http_status)); + if (cert_expiry_epoch != 0) { + t.tls_cert_expiry_epoch = cert_expiry_epoch; + } if (http_status >= 200 && http_status < 400) { std::string rname; if (!parse_ok) { diff --git a/src/handlers/netprobe/NetProbeStreamHandler.h b/src/handlers/netprobe/NetProbeStreamHandler.h index 7068e15c4..889d5bca2 100644 --- a/src/handlers/netprobe/NetProbeStreamHandler.h +++ b/src/handlers/netprobe/NetProbeStreamHandler.h @@ -56,13 +56,17 @@ struct Target { Counter dns_failures; Counter timed_out; Counter http_status_failures; + Counter content_failures; TopN top_status_codes; Counter dns_response_failures; TopN top_rcodes; + uint64_t tls_cert_expiry_epoch{0}; + Counter tls_cert_expiry; Quantile q_dns_us; Quantile q_connect_us; Quantile q_tls_us; Quantile q_ttfb_us; + Quantile q_response_size; Target() : q_time_us(NET_PROBE_SCHEMA, {"response_quantiles_us"}, "Net Probe quantile in microseconds") @@ -74,14 +78,17 @@ struct Target { , connect_failures(NET_PROBE_SCHEMA, {"connect_failures"}, "Total Net Probe failures when performing a TCP socket connection") , dns_failures(NET_PROBE_SCHEMA, {"dns_lookup_failures"}, "Total Net Probe failures when performing a DNS lookup") , timed_out(NET_PROBE_SCHEMA, {"packets_timeout"}, "Total Net Probe timeout transactions") - , http_status_failures(NET_PROBE_SCHEMA, {"http_status_failures"}, "Total HTTP/DoH responses with a non-success status (any HTTP status outside 2xx/3xx, e.g. 4xx/5xx)") + , http_status_failures(NET_PROBE_SCHEMA, {"http_status_failures"}, "Total HTTP/DoH responses whose HTTP status failed the configured status checks (default: any status outside 2xx/3xx)") + , content_failures(NET_PROBE_SCHEMA, {"content_failures"}, "Total HTTP responses whose status passed but response-body checks failed") , top_status_codes(NET_PROBE_SCHEMA, "status_code", {"top_status_codes"}, "Top HTTP status codes") , dns_response_failures(NET_PROBE_SCHEMA, {"dns_response_failures"}, "Total DoH responses with a success HTTP status (2xx/3xx) but a non-NOERROR or unparseable DNS response") , top_rcodes(NET_PROBE_SCHEMA, "rcode", {"top_rcodes"}, "Top DNS response codes observed") + , tls_cert_expiry(NET_PROBE_SCHEMA, {"tls_cert_expiry_epoch_sec"}, "Unix timestamp (seconds) of the earliest notAfter in the target's presented TLS certificate chain") , q_dns_us(NET_PROBE_SCHEMA, {"response_dns_us"}, "DNS resolution time quantiles in microseconds") , q_connect_us(NET_PROBE_SCHEMA, {"response_connect_us"}, "TCP connect time quantiles in microseconds") , q_tls_us(NET_PROBE_SCHEMA, {"response_tls_us"}, "TLS handshake time quantiles in microseconds") , q_ttfb_us(NET_PROBE_SCHEMA, {"response_ttfb_us"}, "Time-to-first-byte quantiles in microseconds") + , q_response_size(NET_PROBE_SCHEMA, {"response_size_bytes"}, "Response size quantiles in bytes") { } }; diff --git a/src/handlers/netprobe/test_net_probe.cpp b/src/handlers/netprobe/test_net_probe.cpp index f8f9cf0d4..3c5de2dea 100644 --- a/src/handlers/netprobe/test_net_probe.cpp +++ b/src/handlers/netprobe/test_net_probe.cpp @@ -52,6 +52,32 @@ struct UnitFixture { NetProbeMetricsManager *manager() { return const_cast(handler->metrics()); } }; +// Same as UnitFixture but enables the "quantiles" group (needed for response_size_bytes, +// which is fed under group::NetProbeMetrics::Quantiles). +struct QuantilesFixture { + visor::Config c; + NetProbeInputStream stream; + visor::InputEventProxy *proxy; + std::unique_ptr handler; + + explicit QuantilesFixture(const std::string &name, uint64_t num_periods = 1) + : stream(name) + { + c.config_set("num_periods", num_periods); + proxy = stream.add_event_proxy(c); + handler = std::make_unique(name, proxy, &c); + handler->config_set("enable", {"quantiles"}); + handler->start(); + } + + ~QuantilesFixture() + { + handler->stop(); + } + + NetProbeMetricsManager *manager() { return const_cast(handler->metrics()); } +}; + } TEST_CASE("Net Probe ping tests", "[netprobe][ping]") @@ -582,6 +608,147 @@ TEST_CASE("NetProbe HTTP metrics merge across buckets", "[netprobe][http][unit]" REQUIRE(j["targets"]["shared"].contains("top_status_codes")); } +TEST_CASE("NetProbe HTTP classification: content_failures vs successes vs http_status_failures", "[netprobe][http][unit]") +{ + UnitFixture fx; + + timespec stamp{9000, 0}; + auto timings = visor::http::HttpTimings{100, 10, 20, 0, 40}; + + auto make_sample = [&timings](bool status_ok, uint8_t content_check) { + visor::http::HttpSample s; + s.status = status_ok ? 200 : 500; + s.status_ok = status_ok; + s.content_check = content_check; + s.timings = timings; + return s; + }; + + fx.manager()->process_netprobe_http_result(make_sample(true, 0), "t1", stamp); // NotChecked → successes + fx.manager()->process_netprobe_http_result(make_sample(true, 1), "t1", stamp); // Match → successes + fx.manager()->process_netprobe_http_result(make_sample(true, 2), "t1", stamp); // Mismatch → content_failures, NOT successes + fx.manager()->process_netprobe_http_result(make_sample(false, 2), "t1", stamp); // bad status → http_status_failures (content ignored) + + json j; + fx.manager()->bucket(0)->to_json(j); + + CHECK(j["targets"]["t1"]["successes"] == 2); + CHECK(j["targets"]["t1"]["content_failures"] == 1); + CHECK(j["targets"]["t1"]["http_status_failures"] == 1); +} + +TEST_CASE("NetProbe HTTP tls_cert_expiry_epoch_sec: latest non-zero wins, zero sample leaves unchanged, absent when never set", "[netprobe][http][unit]") +{ + UnitFixture fx; + + timespec stamp{9100, 0}; + auto timings = visor::http::HttpTimings{100, 10, 20, 0, 40}; + + auto make_sample = [&timings](uint64_t cert_expiry_epoch) { + visor::http::HttpSample s; + s.status = 200; + s.status_ok = true; + s.cert_expiry_epoch = cert_expiry_epoch; + s.timings = timings; + return s; + }; + + // absent when never set (cert_expiry_epoch == 0 on every sample for this target) + fx.manager()->process_netprobe_http_result(make_sample(0), "no-cert", stamp); + json j0; + fx.manager()->bucket(0)->to_json(j0); + CHECK(!j0["targets"]["no-cert"].contains("tls_cert_expiry_epoch_sec")); + + fx.manager()->process_netprobe_http_result(make_sample(1786795200), "t1", stamp); + json j1; + fx.manager()->bucket(0)->to_json(j1); + CHECK(j1["targets"]["t1"]["tls_cert_expiry_epoch_sec"] == 1786795200); + + // a later sample with a SMALLER non-zero value REPLACES it (latest-wins, not max) + fx.manager()->process_netprobe_http_result(make_sample(1700000000), "t1", stamp); + json j2; + fx.manager()->bucket(0)->to_json(j2); + CHECK(j2["targets"]["t1"]["tls_cert_expiry_epoch_sec"] == 1700000000); + + // a zero sample leaves the prior non-zero value unchanged + fx.manager()->process_netprobe_http_result(make_sample(0), "t1", stamp); + json j3; + fx.manager()->bucket(0)->to_json(j3); + CHECK(j3["targets"]["t1"]["tls_cert_expiry_epoch_sec"] == 1700000000); +} + +TEST_CASE("NetProbe HTTP response_size_bytes: quantiles present when enabled", "[netprobe][http][unit]") +{ + QuantilesFixture fx("netprobe-http-respsize"); + + timespec stamp{9200, 0}; + visor::http::HttpSample sample; + sample.status = 200; + sample.status_ok = true; + sample.response_size = 512; + sample.timings = visor::http::HttpTimings{100, 10, 20, 0, 40}; + fx.manager()->process_netprobe_http_result(sample, "respsize-tgt", stamp); + + json j; + fx.manager()->bucket(0)->to_json(j); + + REQUIRE(j["targets"]["respsize-tgt"].contains("response_size_bytes")); + CHECK(j["targets"]["respsize-tgt"]["response_size_bytes"]["p50"] == 512); +} + +TEST_CASE("NetProbe HTTP response_size_bytes: absent when quantiles group not enabled", "[netprobe][http][unit]") +{ + // Default fixture has Counters + Histograms enabled, NOT Quantiles + UnitFixture fx; + + timespec stamp{9300, 0}; + visor::http::HttpSample sample; + sample.status = 200; + sample.status_ok = true; + sample.response_size = 512; + sample.timings = visor::http::HttpTimings{100, 10, 20, 0, 40}; + fx.manager()->process_netprobe_http_result(sample, "no-respsize", stamp); + + json j; + fx.manager()->bucket(0)->to_json(j); + + CHECK(!j["targets"]["no-respsize"].contains("response_size_bytes")); +} + +TEST_CASE("NetProbe HTTP merge: content_failures sums, tls_cert_expiry_epoch_sec takes max, q_response_size survives merge", "[netprobe][http][unit]") +{ + QuantilesFixture fx_a("netprobe-http-merge-a", 2); + QuantilesFixture fx_b("netprobe-http-merge-b", 2); + + timespec stamp{9400, 0}; + auto make_sample = [](bool status_ok, uint8_t content_check, uint64_t cert_expiry_epoch, uint64_t response_size) { + visor::http::HttpSample s; + s.status = status_ok ? 200 : 500; + s.status_ok = status_ok; + s.content_check = content_check; + s.cert_expiry_epoch = cert_expiry_epoch; + s.response_size = response_size; + s.timings = visor::http::HttpTimings{100, 10, 20, 0, 40}; + return s; + }; + + fx_a.manager()->process_netprobe_http_result(make_sample(true, 2, 1700000000, 256), "shared", stamp); + fx_a.manager()->process_netprobe_http_result(make_sample(true, 2, 0, 512), "shared", stamp); + fx_b.manager()->process_netprobe_http_result(make_sample(true, 2, 1800000000, 1024), "shared", stamp); + + QuantilesFixture fx_merged("netprobe-http-merge-out", 2); + auto *merged = const_cast(fx_merged.manager()->bucket(0)); + merged->specialized_merge(*fx_a.manager()->bucket(0), visor::Metric::Aggregate::DEFAULT); + merged->specialized_merge(*fx_b.manager()->bucket(0), visor::Metric::Aggregate::DEFAULT); + + json j; + merged->to_json(j); + + CHECK(j["targets"]["shared"]["content_failures"] == 3); + CHECK(j["targets"]["shared"]["tls_cert_expiry_epoch_sec"] == 1800000000); + REQUIRE(j["targets"]["shared"].contains("response_size_bytes")); +} + TEST_CASE("NetProbe DoH DNS-aware metrics: counters and top_rcodes", "[netprobe][doh][unit]") { UnitFixture fx; @@ -641,6 +808,20 @@ TEST_CASE("NetProbe DoH DNS-aware metrics: counters and top_rcodes", "[netprobe] CHECK(j["targets"]["t1"].contains("response_max_us")); } +TEST_CASE("NetProbe DoH populates tls_cert_expiry_epoch_sec", "[netprobe][doh][unit]") +{ + UnitFixture fx; + + timespec stamp{9500, 0}; + auto timings = visor::http::HttpTimings{100, 10, 20, 30, 40}; + fx.manager()->process_netprobe_doh_result(200, 0, true, 1786795200, timings, "t1", stamp); + + json j; + fx.manager()->bucket(0)->to_json(j); + + CHECK(j["targets"]["t1"]["tls_cert_expiry_epoch_sec"] == 1786795200); +} + TEST_CASE("NetProbe DoH http_response_phases group: quantiles present when enabled", "[netprobe][doh][unit]") { visor::Config c; From bb49c3d9205dd7096d92ad63bf9eac78f5628e7f Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:30:18 -0300 Subject: [PATCH 06/14] =?UTF-8?q?test(netprobe):=20v2=20e2e=20=E2=80=94=20?= =?UTF-8?q?headers/auth,=20status=20sets,=20body=20checks,=20POST=20body,?= =?UTF-8?q?=20redaction;=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/handlers/netprobe/README.md | 48 +++- src/inputs/netprobe/test_netprobe.cpp | 327 ++++++++++++++++++++++++++ 2 files changed, 369 insertions(+), 6 deletions(-) diff --git a/src/handlers/netprobe/README.md b/src/handlers/netprobe/README.md index 10488dec2..dc6a97afe 100644 --- a/src/handlers/netprobe/README.md +++ b/src/handlers/netprobe/README.md @@ -32,15 +32,31 @@ Unlike ping/tcp, HTTP targets are specified as full URLs. | `targets..target` | string | — | Full URL to probe, e.g. `http://example.com/health` | | `interval_msec` | uint64 | 5000 | How often to issue a probe, in milliseconds | | `timeout_msec` | uint64 | 2000 | Per-request timeout in milliseconds (must not exceed `interval_msec`). `0` disables the per-request timeout — not recommended for HTTP, where a slow/stalled server could leave a transfer pending; keep the default. | -| `http_method` | string | `"GET"` | HTTP method to use for all targets (`GET`, `HEAD`, `POST`, …) | +| `http_method` | string | `"GET"` | HTTP method to use for all targets (`GET`, `HEAD`, `POST`, `PUT`, `PATCH`, …) | +| `expected_status` | list of strings | *(unset)* | Status codes/classes that count as a **success**. Entries: exact code (`"200"`), a class shorthand (`"2xx"`), or a range (`"200-299"`). When unset, the default is any `2xx`/`3xx` status. Overridden per-response by `failure_status` (see below). | +| `failure_status` | list of strings | *(unset)* | Status codes/classes that **always** count as a failure (`http_status_failures`), even if they also match `expected_status` or the default 2xx/3xx range. Same grammar as `expected_status`. Evaluation order per response: `failure_status` is checked first; if it matches, the response fails regardless of anything else. | +| `expected_body` | string | *(unset)* | Substring that must appear in the response body for the probe to succeed. Only evaluated when the HTTP status already passed (`expected_status`/`failure_status`/default). Combines with `expected_body_regex` using **AND** — both must match if both are set. A body-check failure on an otherwise-successful status is counted as `content_failures`, not `http_status_failures`. | +| `expected_body_regex` | string | *(unset)* | Regex (ECMAScript syntax) that must match somewhere in the response body. Same AND semantics and `content_failures` accounting as `expected_body`. | +| `body` | string | *(unset)* | Request body to send. Only valid when `http_method` is `POST`, `PUT`, or `PATCH` — set on any other method throws a config error at start. | +| `targets..headers` | map | *(unset)* | Per-target HTTP headers (name → value), e.g. `Authorization: Bearer `. Sent only on requests to that target. **Redaction note:** header *values* are never echoed back by `info_json`/status output — they're replaced with ``. Header *names* (e.g. `Authorization`) are still surfaced (as `header_names`) since they're useful for debugging and carry no secret. | +| `proxy` | string | *(unset)* | HTTP/HTTPS proxy URL for all targets, e.g. `http://user:pass@proxy.example:3128`. Applies to `http` and `doh` only (not `tcp`/`ping`). Like headers, the configured value is fully redacted (``) wherever config is echoed back — even the host/port, since the whole string can carry embedded credentials. | +| `tls.verify` | bool | `true` | Whether to verify the target's TLS certificate/hostname. Set `false` only for testing against self-signed endpoints. | +| `tls.ca_file` | string | *(unset)* | Path to a CA bundle to trust in addition to (or instead of) the system store. Must exist at start time or the stream fails to start. | +| `tls.cert_file` / `tls.key_file` | string | *(unset)* | Client certificate + private key for mutual TLS (mTLS). Must be set together — setting one without the other is a config error. | #### Success semantics -HTTP probes classify results by status code: +HTTP probes classify results, per response, in this order: -- **2xx / 3xx** → counted as a `success` -- **4xx / 5xx** (or any other non-2xx/3xx status including `0` / `1xx`) → counted as an `http_status_failures` -- Transport errors (DNS resolution failure, TCP connect failure, timeout) → counted in the corresponding existing failure counter (`dns_lookup_failures`, `connect_failures`, `packets_timeout`) +1. **`failure_status` match** → always `http_status_failures`, regardless of anything else below. +2. Otherwise, **status check**: `expected_status` if configured, else the default (**2xx**/**3xx** → pass, anything else → `http_status_failures`). +3. If the status check passed and `expected_body`/`expected_body_regex` are configured: body check failure → `content_failures`; body check pass (or no body check configured) → `successes`. + +So the precedence is: `failure_status` wins over `expected_status`/default, and body checks are only ever evaluated on an already-passing status. + +Transport errors (DNS resolution failure, TCP connect failure, timeout) are counted in the corresponding existing failure counter (`dns_lookup_failures`, `connect_failures`, `packets_timeout`) and never reach status/body evaluation. + +**Default User-Agent:** unless a request otherwise sets its own, probes send `User-Agent: pktvisor/`. ### doh @@ -58,6 +74,10 @@ Like `http`, targets are specified as full URLs (e.g. `https://1.1.1.1/dns-query | `interval_msec` | uint64 | 5000 | How often to issue a probe, in milliseconds | | `timeout_msec` | uint64 | 2000 | Per-request timeout in milliseconds (must not exceed `interval_msec`) | | `http_method` | string | `"POST"` | HTTP method to use for the DoH wire-format query (`POST` or `GET`) | +| `proxy` | string | *(unset)* | Same as the `http` test type — see above. | +| `tls.verify` / `tls.ca_file` / `tls.cert_file` / `tls.key_file` | — | *(unset)* | Same as the `http` test type — see above. | + +`expected_status`, `failure_status`, `expected_body`, `expected_body_regex`, `body`, and per-target `headers` are **HTTP-only** and are rejected at config time for `test_type: doh` (a DoH response's "content" is the DNS answer, evaluated via `qname`/`qtype`/rcode instead — see below). #### Success semantics @@ -127,10 +147,14 @@ All metrics are per-target (keyed by the name given in the `targets` config map) | `connect_failures` | TCP/socket connection failures | | `dns_lookup_failures` | DNS resolution failures | | `packets_timeout` | Probes that timed out | -| `http_status_failures` | HTTP/DoH responses with any HTTP status outside 2xx/3xx (e.g. 4xx/5xx, and also 1xx or 0) | +| `http_status_failures` | HTTP/DoH responses whose HTTP status failed the configured status checks (default: any status outside 2xx/3xx). See [Success semantics](#success-semantics) above for the full `failure_status`/`expected_status` precedence — this counter fires whenever that evaluation lands on "fail," whether by the default 2xx/3xx rule, an `expected_status` miss, or a `failure_status` hit. | +| `content_failures` | HTTP responses whose status passed the status check but the configured `expected_body`/`expected_body_regex` check(s) did not match. Never incremented together with `successes` or `http_status_failures` for the same response — HTTP-only (not applicable to `doh`, which has no body-check config). | | `top_status_codes` | Top HTTP status codes observed (e.g. `"200"`, `"404"`, `"503"`) | | `dns_response_failures` | DoH responses with HTTP 2xx/3xx but a non-NOERROR or unparseable DNS rcode | | `top_rcodes` | Top DNS response codes observed in DoH probes (e.g. `"NOERROR"`, `"NXDOMAIN"`, `"SRVFAIL"`, `"PARSE_ERROR"`) | +| `tls_cert_expiry_epoch_sec` | Unix timestamp (seconds) of the earliest `notAfter` in the target's presented TLS certificate chain. Only present once at least one sample has carried cert info (absent entirely for plain-HTTP targets). On merge/rollup, the **latest non-zero value wins** (last-known-good, not max-of-window) — see the keep-alive note below for why this matters. Example alerting expression: fire a warning when `tls_cert_expiry_epoch_sec - time() < 14 * 86400` (certificate expires within 14 days). | + +**Keep-alive / cert-cache note:** each netprobe input stream shares one libcurl multi-handle (connection pool) across all its `http`/`doh` targets, so TCP+TLS connections are reused (keep-alive) across probe intervals whenever the server allows it. libcurl's `CERTINFO` is only populated on transfers that actually perform a fresh TLS handshake — a request served over a reused pooled connection reports no cert info at all. To keep `tls_cert_expiry_epoch_sec` from flapping to "absent" every time a connection is reused, the probe caches the last known expiry per target and reports it on every sample until a fresh handshake supersedes it. ### Histograms (group: `histograms`, default ON) @@ -145,6 +169,7 @@ All metrics are per-target (keyed by the name given in the `targets` config map) | Metric | Description | |--------|-------------| | `response_quantiles_us` | Quantiles of total response times in microseconds | +| `response_size_bytes` | Quantiles of HTTP/DoH response body size in bytes | ### HTTP response phases (group: `http_response_phases`, opt-in) @@ -193,3 +218,14 @@ handlers: enable: - http_response_phases ``` + +--- + +## Testing notes + +Automated test coverage (`test_netprobe.cpp`) exercises the `tls.*` config-validation paths +(`tls.cert_file`/`tls.key_file` pairing, `tls.ca_file` existence, `tls`/`proxy` rejected for `tcp`), but does +**not** stand up a real TLS server or perform a live mTLS handshake — CI has no fixture for issuing/validating +certificates over the wire. If you change the mTLS wiring (`tls.cert_file`/`tls.key_file`/`tls.ca_file`/`tls.verify` +plumbing into libcurl), do a manual smoke test against a real mTLS-enabled endpoint (e.g. a local nginx/envoy with +`ssl_verify_client on`) before merging. diff --git a/src/inputs/netprobe/test_netprobe.cpp b/src/inputs/netprobe/test_netprobe.cpp index 727835e4c..141cd8648 100644 --- a/src/inputs/netprobe/test_netprobe.cpp +++ b/src/inputs/netprobe/test_netprobe.cpp @@ -7,7 +7,9 @@ #include #include #include +#include #include +#include #include #include #ifdef __GNUC__ @@ -1082,3 +1084,328 @@ TEST_CASE("NetProbe DoH e2e: root qname (dot) probe succeeds", "[netprobe][doh][ CHECK(tgt["attempts"].get() >= 1); CHECK(tgt["successes"].get() >= 1); } + +// --------------------------------------------------------------------------- +// End-to-end v2 tests: real NetProbeInputStream + NetProbeStreamHandler proving +// the evaluation precedence (failure_status > expected_status > default 2xx/3xx, +// then body checks) through a LIVE probe, not just unit-level HttpSample fixtures. +// Mirrors the http/doh e2e scaffolding above: ServerGuard, wait_until_ready(), +// ephemeral ports, 200ms interval / 150ms timeout / ~750ms sleep. +// --------------------------------------------------------------------------- + +TEST_CASE("NetProbe HTTP e2e v2: per-target headers determine per-target auth outcome", "[netprobe][http][e2e]") +{ + // /auth returns 200 iff Authorization == "Bearer sekrit", else 401 — proves per-target headers + // are actually threaded through to the outbound request, not just accepted by config validation. + httplib::Server svr; + svr.Get("/auth", [](const httplib::Request &req, httplib::Response &res) { + if (req.get_header_value("Authorization") == "Bearer sekrit") { + res.status = 200; + res.set_content("ok", "text/plain"); + } else { + res.status = 401; + res.set_content("nope", "text/plain"); + } + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/auth"; + + NetProbeInputStream stream{"netprobe-http-e2e-headers"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + auto targets = std::make_shared(); + + // Target A carries the correct Authorization header. + auto target_a = std::make_shared(); + target_a->config_set("target", url); + auto headers = std::make_shared(); + headers->config_set("Authorization", std::string("Bearer sekrit")); + target_a->config_set>("headers", headers); + targets->config_set>("with_auth", target_a); + + // Target B hits the same URL with no headers at all. + auto target_b = std::make_shared(); + target_b->config_set("target", url); + targets->config_set>("no_auth", target_b); + + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-headers", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + json j; + handler.metrics()->bucket(0)->to_json(j); + + REQUIRE(j["targets"].contains("with_auth")); + CHECK(j["targets"]["with_auth"]["successes"].get() >= 1); + + REQUIRE(j["targets"].contains("no_auth")); + auto &no_auth = j["targets"]["no_auth"]; + CHECK(no_auth["http_status_failures"].get() >= 1); + REQUIRE(no_auth.contains("top_status_codes")); + bool found_401 = false; + for (const auto &entry : no_auth["top_status_codes"]) { + if (entry.contains("name") && entry["name"] == "401") { + found_401 = true; + } + } + CHECK(found_401); +} + +TEST_CASE("NetProbe HTTP e2e v2: expected_status flips a 401 endpoint into a success", "[netprobe][http][e2e]") +{ + httplib::Server svr; + svr.Get("/unauth", [](const httplib::Request &, httplib::Response &res) { + res.status = 401; + res.set_content("nope", "text/plain"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/unauth"; + + NetProbeInputStream stream{"netprobe-http-e2e-expected-status"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + stream.config_set("expected_status", {"401"}); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + targets->config_set>("unauth_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-expected-status", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + json j; + handler.metrics()->bucket(0)->to_json(j); + + REQUIRE(j["targets"].contains("unauth_target")); + auto &tgt = j["targets"]["unauth_target"]; + CHECK(tgt["successes"].get() >= 1); + CHECK(tgt["http_status_failures"].get() == 0); +} + +TEST_CASE("NetProbe HTTP e2e v2: failure_status wins over expected_status", "[netprobe][http][e2e]") +{ + // expected_status accepts the whole 2xx class, but failure_status carves 200 back out of it. + // failure_status must win: a 200 response must be counted as a failure, never a success. + httplib::Server svr; + svr.Get("/ok", [](const httplib::Request &, httplib::Response &res) { + res.set_content("ok", "text/plain"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/ok"; + + NetProbeInputStream stream{"netprobe-http-e2e-failure-wins"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + stream.config_set("expected_status", {"2xx"}); + stream.config_set("failure_status", {"200"}); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + targets->config_set>("ok_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-failure-wins", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + json j; + handler.metrics()->bucket(0)->to_json(j); + + REQUIRE(j["targets"].contains("ok_target")); + auto &tgt = j["targets"]["ok_target"]; + CHECK(tgt["http_status_failures"].get() >= 1); + CHECK(tgt["successes"].get() == 0); +} + +TEST_CASE("NetProbe HTTP e2e v2: expected_body + expected_body_regex both match -> success", "[netprobe][http][e2e]") +{ + httplib::Server svr; + svr.Get("/health", [](const httplib::Request &, httplib::Response &res) { + res.set_content(R"({"status":"ok","state":"up"})", "application/json"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/health"; + + NetProbeInputStream stream{"netprobe-http-e2e-body-match"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + stream.config_set("expected_body", std::string("\"status\":\"ok\"")); + stream.config_set("expected_body_regex", std::string("up|healthy")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + targets->config_set>("health_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-body-match", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + json j; + handler.metrics()->bucket(0)->to_json(j); + + REQUIRE(j["targets"].contains("health_target")); + auto &tgt = j["targets"]["health_target"]; + CHECK(tgt["successes"].get() >= 1); + CHECK(tgt["content_failures"].get() == 0); +} + +TEST_CASE("NetProbe HTTP e2e v2: expected_body mismatch -> content_failures, never successes", "[netprobe][http][e2e]") +{ + httplib::Server svr; + svr.Get("/health", [](const httplib::Request &, httplib::Response &res) { + res.set_content(R"({"status":"ok","state":"up"})", "application/json"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/health"; + + NetProbeInputStream stream{"netprobe-http-e2e-body-mismatch"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + stream.config_set("expected_body", std::string("nope")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + targets->config_set>("health_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-body-mismatch", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + json j; + handler.metrics()->bucket(0)->to_json(j); + + REQUIRE(j["targets"].contains("health_target")); + auto &tgt = j["targets"]["health_target"]; + // Bulletproof invariant: successes must be exactly zero when the body check fails. + CHECK(tgt["successes"].get() == 0); + CHECK(tgt["content_failures"].get() >= 1); +} + +TEST_CASE("NetProbe HTTP e2e v2: POST body is delivered to the server", "[netprobe][http][e2e]") +{ + std::atomic body_seen{false}; + std::mutex body_mutex; + std::string seen_body; + + httplib::Server svr; + svr.Post("/echo-len", [&](const httplib::Request &req, httplib::Response &res) { + { + std::lock_guard lock(body_mutex); + seen_body = req.body; + } + body_seen = true; + res.set_content("ok", "text/plain"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/echo-len"; + + NetProbeInputStream stream{"netprobe-http-e2e-post-body"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + stream.config_set("http_method", std::string("POST")); + stream.config_set("body", std::string(R"({"ping":true})")); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + targets->config_set>("echo_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-post-body", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + REQUIRE(body_seen.load()); + { + std::lock_guard lock(body_mutex); + CHECK(seen_body == R"({"ping":true})"); + } + + json j; + handler.metrics()->bucket(0)->to_json(j); + REQUIRE(j["targets"].contains("echo_target")); + CHECK(j["targets"]["echo_target"]["successes"].get() >= 1); +} From 2a43a61c78a32e832fdd9dcca5c909bad645d588 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:50:51 -0300 Subject: [PATCH 07/14] fix(netprobe): redact tap-config secrets via an input-plugin hook (GET /api/v1/taps echoed raw headers/proxy/body) --- src/InputModulePlugin.h | 11 +++ src/Taps.h | 4 + src/handlers/netprobe/README.md | 2 +- .../netprobe/NetProbeInputModulePlugin.h | 7 ++ src/inputs/netprobe/NetProbeInputStream.cpp | 77 ++++++++++++------- src/inputs/netprobe/NetProbeInputStream.h | 5 ++ src/inputs/netprobe/test_netprobe.cpp | 33 ++++++++ 7 files changed, 109 insertions(+), 30 deletions(-) diff --git a/src/InputModulePlugin.h b/src/InputModulePlugin.h index ce56ec096..a584266af 100644 --- a/src/InputModulePlugin.h +++ b/src/InputModulePlugin.h @@ -6,6 +6,7 @@ #include "AbstractPlugin.h" #include +#include #include namespace visor { @@ -32,6 +33,16 @@ class InputModulePlugin : public AbstractPlugin virtual std::unique_ptr instantiate(const std::string name, const Configurable *config, const Configurable *filter) = 0; virtual std::string generate_input_name(std::string prefix, const Configurable &config, const Configurable &filter) = 0; + + /** + * Redact secret-bearing values from a raw config-echo JSON node for this input type. + * Called wherever a config holding this input's keys is serialized verbatim (e.g. + * Tap::info_json, exposed via the admin API) so credentials configured for the input + * (auth headers, proxy URLs, request bodies, ...) never leave the process. Default: no-op. + */ + virtual void redact_config_json(nlohmann::json &) const + { + } }; using InputPluginPtr = std::unique_ptr; diff --git a/src/Taps.h b/src/Taps.h index 3f51f0d58..95d2271c7 100644 --- a/src/Taps.h +++ b/src/Taps.h @@ -59,6 +59,10 @@ class Tap : public AbstractModule j["input_type"] = _input_plugin->plugin(); j["interface"] = _input_plugin->pluginInterface(); config_json(j["config"]); + // The tap config is a raw echo of operator config and may hold input-type-specific + // secrets (e.g. netprobe auth headers/proxy/body). Let the input plugin redact them + // before this JSON reaches the admin API (GET /api/v1/taps, Policy::info_json). + _input_plugin->redact_config_json(j["config"]); _tags->config_json(j["tags"]); } diff --git a/src/handlers/netprobe/README.md b/src/handlers/netprobe/README.md index dc6a97afe..54f51d47f 100644 --- a/src/handlers/netprobe/README.md +++ b/src/handlers/netprobe/README.md @@ -169,7 +169,7 @@ All metrics are per-target (keyed by the name given in the `targets` config map) | Metric | Description | |--------|-------------| | `response_quantiles_us` | Quantiles of total response times in microseconds | -| `response_size_bytes` | Quantiles of HTTP/DoH response body size in bytes | +| `response_size_bytes` | Quantiles of HTTP response body size in bytes (`http` test type only) | ### HTTP response phases (group: `http_response_phases`, opt-in) diff --git a/src/inputs/netprobe/NetProbeInputModulePlugin.h b/src/inputs/netprobe/NetProbeInputModulePlugin.h index a0f67ad9d..7c694b7fd 100644 --- a/src/inputs/netprobe/NetProbeInputModulePlugin.h +++ b/src/inputs/netprobe/NetProbeInputModulePlugin.h @@ -23,6 +23,13 @@ class NetProbeInputModulePlugin : public visor::InputModulePlugin std::unique_ptr instantiate(const std::string name, const Configurable *config, const Configurable *filter) override; std::string generate_input_name(std::string prefix, const Configurable &config, const Configurable &filter) override; + + void redact_config_json(nlohmann::json &cfg) const override + { + // Netprobe config can carry secrets (auth headers, proxy credentials, request bodies); + // scrub them from any raw config echo (e.g. the tap echo on the admin API). + scrub_netprobe_config_json(cfg); + } }; } diff --git a/src/inputs/netprobe/NetProbeInputStream.cpp b/src/inputs/netprobe/NetProbeInputStream.cpp index db1cfed5d..57d9f952b 100644 --- a/src/inputs/netprobe/NetProbeInputStream.cpp +++ b/src/inputs/netprobe/NetProbeInputStream.cpp @@ -52,25 +52,32 @@ std::string test_type_name(TestType t) return "unknown"; } -// Join a per-target "headers" sub-Configurable entry into its "name: value" string. The YAML -// loader stores scalars typed (uint64_t/bool/string), so a header value like `12345` or `true` -// is NOT a std::string in the Configurable and config_get() throws on it. Configurable +// Read a scalar config value as a string regardless of how the YAML loader typed it. The loader +// stores scalars typed (uint64_t/bool/string), so a value like `12345` or `true` is NOT a +// std::string in the Configurable and config_get() throws on it. Configurable // exposes no cheaper type-dispatch accessor, so fall back through the scalar types it can hold. -std::string header_value_to_string(const visor::Configurable &headers, const std::string &name) +// `what` names the key in the error (never the value — it may carry a secret). +std::string scalar_config_to_string(const visor::Configurable &cfg, const std::string &key, const char *what) { try { - return headers.config_get(name); + return cfg.config_get(key); } catch (const visor::ConfigException &) { } try { - return std::to_string(headers.config_get(name)); + return std::to_string(cfg.config_get(key)); } catch (const visor::ConfigException &) { } try { - return headers.config_get(name) ? "true" : "false"; + return cfg.config_get(key) ? "true" : "false"; } catch (const visor::ConfigException &) { } - throw NetProbeException(fmt::format("netprobe: header '{}' has an unsupported value type", name)); + throw NetProbeException(fmt::format("netprobe: {} '{}' has an unsupported value type", what, key)); +} + +// Join a per-target "headers" sub-Configurable entry into its "name: value" string. +std::string header_value_to_string(const visor::Configurable &headers, const std::string &name) +{ + return scalar_config_to_string(headers, name, "header"); } // Trim leading/trailing ASCII whitespace. @@ -201,8 +208,10 @@ void NetProbeInputStream::start() } } { - std::string sub = config_exists("expected_body") ? config_get("expected_body") : ""; - std::string rx = config_exists("expected_body_regex") ? config_get("expected_body_regex") : ""; + // Tolerant reads: an all-digit YAML value (e.g. body: 12345) is stored typed, and a plain + // config_get would throw a confusing type error. + std::string sub = config_exists("expected_body") ? scalar_config_to_string(*this, "expected_body", "config") : ""; + std::string rx = config_exists("expected_body_regex") ? scalar_config_to_string(*this, "expected_body_regex", "config") : ""; try { _http_opts.body_check = visor::http::BodyCheck::compile(sub, rx); } catch (const std::invalid_argument &e) { @@ -210,10 +219,10 @@ void NetProbeInputStream::start() } } if (config_exists("body")) { - _http_opts.request_body = config_get("body"); + _http_opts.request_body = scalar_config_to_string(*this, "body", "config"); } if (config_exists("proxy")) { - _http_opts.proxy = config_get("proxy"); + _http_opts.proxy = scalar_config_to_string(*this, "proxy", "config"); } if (config_exists("tls")) { auto tls = config_get>("tls"); @@ -608,30 +617,40 @@ void NetProbeInputStream::stop() _running = false; } +void scrub_netprobe_config_json(json &cfg) +{ + // Scrub every config value that can carry a secret from a raw config echo: proxy URLs can + // embed credentials, and header/body/expected_body(_regex) values can be anything the operator + // configured (Authorization headers, tokens in a probe body, etc.). Values must NEVER appear + // in any serialized config — only header/target NAMES are safe. Used by BOTH the input + // stream's info_json (module config echo) and the netprobe input plugin's redact hook (tap + // config echo via Tap::info_json — GET /api/v1/taps and Policy::info_json). + for (const char *key : {"proxy", "body", "expected_body", "expected_body_regex"}) { + if (cfg.contains(key)) { + cfg[key] = ""; + } + } + if (cfg.contains("targets") && cfg["targets"].is_object()) { + for (auto &el : cfg["targets"].items()) { + auto &tgt_val = el.value(); + if (tgt_val.is_object() && tgt_val.contains("headers") && tgt_val["headers"].is_object()) { + for (auto &hel : tgt_val["headers"].items()) { + hel.value() = ""; + } + } + } + } +} + void NetProbeInputStream::info_json(json &j) const { common_info_json(j); // common_info_json() echoes the module's RAW config verbatim at j["module"]["config"] (via - // Configurable::config_json). Scrub every value that can carry a secret before this JSON goes - // anywhere: proxy URLs can embed credentials, and header/body/expected_body(_regex) values can - // be anything the operator configured (Authorization headers, tokens in a probe body, etc.). - // Header/proxy/body values must NEVER appear in info_json — only header/target NAMES are safe. + // Configurable::config_json) — scrub the secret-bearing values before this JSON goes anywhere. if (j.contains("module") && j["module"].contains("config")) { auto &cfg = j["module"]["config"]; - for (const char *key : {"proxy", "body", "expected_body", "expected_body_regex"}) { - if (cfg.contains(key)) { - cfg[key] = ""; - } - } + scrub_netprobe_config_json(cfg); if (cfg.contains("targets") && cfg["targets"].is_object()) { - for (auto &el : cfg["targets"].items()) { - auto &tgt_val = el.value(); - if (tgt_val.is_object() && tgt_val.contains("headers") && tgt_val["headers"].is_object()) { - for (auto &hel : tgt_val["headers"].items()) { - hel.value() = ""; - } - } - } // Per-target header NAMES (never values) are safe to surface and useful for debugging. for (const auto &[tgt_name, names] : _http_target_header_names) { if (cfg["targets"].contains(tgt_name)) { diff --git a/src/inputs/netprobe/NetProbeInputStream.h b/src/inputs/netprobe/NetProbeInputStream.h index 4e4cbff08..ccaa67f51 100644 --- a/src/inputs/netprobe/NetProbeInputStream.h +++ b/src/inputs/netprobe/NetProbeInputStream.h @@ -24,6 +24,11 @@ class HttpClient; namespace visor::input::netprobe { +// Redact every netprobe config value that can carry a secret (proxy, body, expected_body, +// expected_body_regex, and all targets.*.headers values) from a raw config-echo JSON node. +// Shared by NetProbeInputStream::info_json and the input plugin's tap-config redact hook. +void scrub_netprobe_config_json(json &cfg); + class NetProbeInputStream : public visor::InputStream { static const inline uint64_t MAX_PAYLOAD_SIZE = 65500; diff --git a/src/inputs/netprobe/test_netprobe.cpp b/src/inputs/netprobe/test_netprobe.cpp index 141cd8648..aebce17f4 100644 --- a/src/inputs/netprobe/test_netprobe.cpp +++ b/src/inputs/netprobe/test_netprobe.cpp @@ -555,6 +555,39 @@ TEST_CASE("NetProbe HTTP e2e: success path records attempt, success, and 200 in CHECK(found_200); } +TEST_CASE("NetProbe v2 scrub helper: redacts a raw tap-style config echo", "[netprobe][http][config]") +{ + // Tap::info_json echoes the tap's raw config (exposed via GET /api/v1/taps and + // Policy::info_json) and calls the input plugin's redact hook, which uses this helper. + // Feed it a tap-shaped config JSON and prove every secret-bearing value is masked. + json cfg; + cfg["proxy"] = "http://user:pass@myproxy:3128"; + cfg["body"] = "{\"token\":\"tap-body-sekrit\"}"; + cfg["expected_body"] = "tap-expected-sekrit"; + cfg["expected_body_regex"] = "tap-regex-sekrit"; + cfg["interval_msec"] = 200; // non-secret keys must survive untouched + cfg["targets"]["api"]["target"] = "https://api.example.com/health"; + cfg["targets"]["api"]["headers"]["Authorization"] = "Bearer tap-header-sekrit"; + cfg["targets"]["api"]["headers"]["X-Num"] = 12345; + + visor::input::netprobe::scrub_netprobe_config_json(cfg); + + auto dumped = cfg.dump(); + CHECK(dumped.find("tap-header-sekrit") == std::string::npos); + CHECK(dumped.find("tap-body-sekrit") == std::string::npos); + CHECK(dumped.find("tap-expected-sekrit") == std::string::npos); + CHECK(dumped.find("tap-regex-sekrit") == std::string::npos); + CHECK(dumped.find("user:pass") == std::string::npos); + CHECK(dumped.find("12345") == std::string::npos); + // Names and non-secret values survive. + CHECK(dumped.find("Authorization") != std::string::npos); + CHECK(dumped.find("X-Num") != std::string::npos); + CHECK(cfg["interval_msec"] == 200); + CHECK(cfg["targets"]["api"]["target"] == "https://api.example.com/health"); + CHECK(cfg["proxy"] == ""); + CHECK(cfg["targets"]["api"]["headers"]["Authorization"] == ""); +} + TEST_CASE("NetProbe v2 info_json: proxy/body/expected_body(_regex)/header values are scrubbed", "[netprobe][http][config]") { // common_info_json() echoes the raw module config verbatim; without scrubbing this would leak From f7b8d411da5abc8d25fb3c010dd32f83ad08aee4 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:44:06 -0300 Subject: [PATCH 08/14] fix(netprobe): don't classify content checks against a truncated body; configurable body_check_max_bytes --- libs/visor_http_client/HttpClient.cpp | 13 +++-- libs/visor_http_client/HttpClient.h | 4 +- libs/visor_http_client/HttpTypes.h | 4 +- libs/visor_http_client/test_http_client.cpp | 58 +++++++++++++++++++++ src/handlers/netprobe/README.md | 1 + src/inputs/netprobe/HttpProbe.cpp | 13 ++++- src/inputs/netprobe/HttpProbeOptions.h | 1 + src/inputs/netprobe/NetProbeInputStream.cpp | 9 +++- src/inputs/netprobe/NetProbeInputStream.h | 1 + src/inputs/netprobe/test_netprobe.cpp | 56 +++++++++++++++++++- 10 files changed, 151 insertions(+), 9 deletions(-) diff --git a/libs/visor_http_client/HttpClient.cpp b/libs/visor_http_client/HttpClient.cpp index bad124b2e..f1afe159b 100644 --- a/libs/visor_http_client/HttpClient.cpp +++ b/libs/visor_http_client/HttpClient.cpp @@ -131,9 +131,14 @@ size_t HttpClient::write_capture(char *ptr, size_t size, size_t nmemb, void *use size_t n = size * nmemb; auto *ctx = static_cast(userdata); if (ctx) { - constexpr size_t kMaxBody = 64 * 1024; // a DNS-over-HTTPS message is well under 64 KB - if (ctx->response.size() < kMaxBody) { - ctx->response.append(ptr, (n < kMaxBody - ctx->response.size()) ? n : (kMaxBody - ctx->response.size())); + if (ctx->response.size() + n > ctx->capture_max) { + // Body exceeds the cap: keep the prefix that fits and flag truncation so the caller + // knows the captured body is partial (a content check can't be evaluated definitively). + size_t room = ctx->capture_max > ctx->response.size() ? ctx->capture_max - ctx->response.size() : 0; + ctx->response.append(ptr, room); + ctx->truncated = true; + } else { + ctx->response.append(ptr, n); } } return n; // always consume so curl doesn't abort the transfer @@ -219,6 +224,7 @@ void HttpClient::request(const HttpRequest &req, ResultCallback on_done) curl_easy_setopt(easy, CURLOPT_HTTPHEADER, ctx->headers); } ctx->capture = req.capture_response; + ctx->capture_max = req.capture_max_bytes; curl_easy_setopt(easy, CURLOPT_WRITEFUNCTION, ctx->capture ? &HttpClient::write_capture : &HttpClient::write_discard); curl_easy_setopt(easy, CURLOPT_WRITEDATA, ctx.get()); curl_easy_setopt(easy, CURLOPT_PRIVATE, ctx.get()); @@ -395,6 +401,7 @@ void HttpClient::check_multi_info() } if (it != _easy.end() && it->second->capture) { result.response_body = std::move(it->second->response); + result.body_truncated = it->second->truncated; } char *ct = nullptr; curl_easy_getinfo(easy, CURLINFO_CONTENT_TYPE, &ct); // may be null (no Content-Type) diff --git a/libs/visor_http_client/HttpClient.h b/libs/visor_http_client/HttpClient.h index 9fb691c28..e342fdbd5 100644 --- a/libs/visor_http_client/HttpClient.h +++ b/libs/visor_http_client/HttpClient.h @@ -45,7 +45,9 @@ class HttpClient char errbuf[CURL_ERROR_SIZE]{}; curl_slist *headers{nullptr}; // owned; freed in dtor (after curl_easy_cleanup) bool capture{false}; - std::string response; // captured body (bounded to 64 KB) + size_t capture_max{64 * 1024}; // cap on captured body bytes (from HttpRequest.capture_max_bytes) + bool truncated{false}; // set by write_capture when the body exceeds capture_max + std::string response; // captured body (bounded to capture_max) ~EasyContext() { if (headers) curl_slist_free_all(headers); } }; // per-socket context: a uvw poll handle curl watches (owned in _sockets below) diff --git a/libs/visor_http_client/HttpTypes.h b/libs/visor_http_client/HttpTypes.h index f0b3a303a..5f8a866a2 100644 --- a/libs/visor_http_client/HttpTypes.h +++ b/libs/visor_http_client/HttpTypes.h @@ -21,6 +21,7 @@ struct HttpRequest { std::string body; // request body bytes (empty => no body) std::vector headers; // extra request headers, each "Key: Value" bool capture_response{false}; // when true, capture the response body + size_t capture_max_bytes{64 * 1024};// cap on captured response bytes; body beyond this is dropped and HttpResult.body_truncated is set bool collect_cert_info{false}; // when true, request CURLOPT_CERTINFO and populate HttpResult.cert_expiry_epoch std::string proxy; // CURLOPT_PROXY value (empty => no proxy) std::string ca_file; // CURLOPT_CAINFO (empty => curl default CA bundle) @@ -33,7 +34,8 @@ struct HttpResult { long curl_code{0}; long status_code{0}; HttpTimings timings; - std::string response_body; // populated only when HttpRequest.capture_response + std::string response_body; // populated only when HttpRequest.capture_response (bounded to capture_max_bytes) + bool body_truncated{false}; // true when the response body exceeded capture_max_bytes (response_body is a prefix) std::string content_type; // raw response Content-Type header when transport_ok (compare case-insensitively) std::string error_msg; // human-readable curl error detail when !transport_ok uint64_t cert_expiry_epoch{0}; // earliest "Expire date:" across the TLS chain when HttpRequest.collect_cert_info; 0 for plain http or on parse failure diff --git a/libs/visor_http_client/test_http_client.cpp b/libs/visor_http_client/test_http_client.cpp index a44f70594..c21d8f195 100644 --- a/libs/visor_http_client/test_http_client.cpp +++ b/libs/visor_http_client/test_http_client.cpp @@ -341,3 +341,61 @@ TEST_CASE("HttpClient v2 transport fields", "[http][client]") svr.stop(); if (server_thread.joinable()) server_thread.join(); } + +TEST_CASE("HttpClient body capture cap + truncation flag", "[http][client]") +{ + httplib::Server svr; + std::string big(200 * 1024, 'a'); // 200 KB + svr.Get("/big", [&](const httplib::Request &, httplib::Response &res) { + res.set_content(big, "text/plain"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + auto loop = uvw::loop::create(); + HttpClient client(loop); + std::string base = "http://127.0.0.1:" + std::to_string(port); + std::vector results; + auto on_done = [&](const HttpResult &r) { results.push_back(r); }; + + SECTION("body over the cap is truncated to the cap and flagged") + { + HttpRequest req; + req.url = base + "/big"; + req.capture_response = true; + req.capture_max_bytes = 1024; + req.timeout_ms = 3000; + client.request(req, on_done); + auto wd = arm_watchdog(loop, 6000); + loop->run(); + disarm_watchdog(loop, wd); + REQUIRE(results.size() == 1); + CHECK(results[0].transport_ok); + CHECK(results[0].body_truncated); + CHECK(results[0].response_body.size() == 1024); + } + SECTION("body under the cap is complete and not flagged") + { + HttpRequest req; + req.url = base + "/big"; + req.capture_response = true; + req.capture_max_bytes = 1024 * 1024; // 1 MB > 200 KB + req.timeout_ms = 3000; + client.request(req, on_done); + auto wd = arm_watchdog(loop, 6000); + loop->run(); + disarm_watchdog(loop, wd); + REQUIRE(results.size() == 1); + CHECK(results[0].transport_ok); + CHECK_FALSE(results[0].body_truncated); + CHECK(results[0].response_body.size() == 200 * 1024); + } + + client.close(); + loop->run(); + svr.stop(); + if (server_thread.joinable()) server_thread.join(); +} diff --git a/src/handlers/netprobe/README.md b/src/handlers/netprobe/README.md index 54f51d47f..3033a2551 100644 --- a/src/handlers/netprobe/README.md +++ b/src/handlers/netprobe/README.md @@ -37,6 +37,7 @@ Unlike ping/tcp, HTTP targets are specified as full URLs. | `failure_status` | list of strings | *(unset)* | Status codes/classes that **always** count as a failure (`http_status_failures`), even if they also match `expected_status` or the default 2xx/3xx range. Same grammar as `expected_status`. Evaluation order per response: `failure_status` is checked first; if it matches, the response fails regardless of anything else. | | `expected_body` | string | *(unset)* | Substring that must appear in the response body for the probe to succeed. Only evaluated when the HTTP status already passed (`expected_status`/`failure_status`/default). Combines with `expected_body_regex` using **AND** — both must match if both are set. A body-check failure on an otherwise-successful status is counted as `content_failures`, not `http_status_failures`. | | `expected_body_regex` | string | *(unset)* | Regex (ECMAScript syntax) that must match somewhere in the response body. Same AND semantics and `content_failures` accounting as `expected_body`. | +| `body_check_max_bytes` | uint64 | 524288 (512 KiB) | Maximum number of response-body bytes captured for `expected_body`/`expected_body_regex` evaluation. If a response body exceeds this, it is truncated and the **body check is skipped** for that sample (classified on status alone, with a warning logged) rather than risk a false `content_failures` on a match that lives past the cap, or an anchored regex matching the truncation boundary. Raise it if your health endpoints return large bodies whose match text is deep in the response. | | `body` | string | *(unset)* | Request body to send. Only valid when `http_method` is `POST`, `PUT`, or `PATCH` — set on any other method throws a config error at start. | | `targets..headers` | map | *(unset)* | Per-target HTTP headers (name → value), e.g. `Authorization: Bearer `. Sent only on requests to that target. **Redaction note:** header *values* are never echoed back by `info_json`/status output — they're replaced with ``. Header *names* (e.g. `Authorization`) are still surfaced (as `header_names`) since they're useful for debugging and carry no secret. | | `proxy` | string | *(unset)* | HTTP/HTTPS proxy URL for all targets, e.g. `http://user:pass@proxy.example:3128`. Applies to `http` and `doh` only (not `tcp`/`ping`). Like headers, the configured value is fully redacted (``) wherever config is echoed back — even the host/port, since the whole string can carry embedded credentials. | diff --git a/src/inputs/netprobe/HttpProbe.cpp b/src/inputs/netprobe/HttpProbe.cpp index 6182d3c26..7b4191e01 100644 --- a/src/inputs/netprobe/HttpProbe.cpp +++ b/src/inputs/netprobe/HttpProbe.cpp @@ -38,6 +38,7 @@ bool HttpProbe::start(std::shared_ptr io_loop) req.verify_tls = _opts.tls_verify; req.collect_cert_info = true; req.capture_response = _opts.body_check.configured(); + req.capture_max_bytes = _opts.body_check_max_bytes; const std::string name = _name; auto http_result = _http_result; auto fail = _fail; @@ -60,7 +61,17 @@ bool HttpProbe::start(std::shared_ptr io_loop) s.status_ok = status_ok; s.content_check = 0; if (status_ok && opts.body_check.configured()) { - s.content_check = opts.body_check.matches(r.response_body) ? 1 : 2; + if (r.body_truncated) { + // The captured body is only a prefix (it exceeded body_check_max_bytes): a + // match beyond the cap would be missed, and an anchored regex could match the + // artificial truncation boundary. We can't authoritatively evaluate the body, + // so classify on status alone (content_check stays NotChecked) and warn. + if (auto logger = spdlog::get("visor")) { + logger->warn("netprobe http[{}]: response body exceeded the {}-byte capture limit; body check skipped (raise body_check_max_bytes)", name, opts.body_check_max_bytes); + } + } else { + s.content_check = opts.body_check.matches(r.response_body) ? 1 : 2; + } } // CERTINFO is only filled on transfers that performed a TLS handshake; reused // pooled connections report nothing. Cache the last known expiry per target so diff --git a/src/inputs/netprobe/HttpProbeOptions.h b/src/inputs/netprobe/HttpProbeOptions.h index cb2f86bb3..c2bbd6126 100644 --- a/src/inputs/netprobe/HttpProbeOptions.h +++ b/src/inputs/netprobe/HttpProbeOptions.h @@ -15,6 +15,7 @@ struct HttpProbeOptions { visor::http::StatusMatcher expected_status; // empty => default 2xx/3xx visor::http::StatusMatcher failure_status; // empty => none visor::http::BodyCheck body_check; // http only + size_t body_check_max_bytes{512 * 1024}; // http only: cap on captured body for body checks; beyond it the check is skipped std::string request_body; // http only std::string proxy; bool tls_verify{true}; diff --git a/src/inputs/netprobe/NetProbeInputStream.cpp b/src/inputs/netprobe/NetProbeInputStream.cpp index 57d9f952b..f13282b1f 100644 --- a/src/inputs/netprobe/NetProbeInputStream.cpp +++ b/src/inputs/netprobe/NetProbeInputStream.cpp @@ -221,6 +221,13 @@ void NetProbeInputStream::start() if (config_exists("body")) { _http_opts.request_body = scalar_config_to_string(*this, "body", "config"); } + if (config_exists("body_check_max_bytes")) { + auto n = config_get("body_check_max_bytes"); + if (n == 0) { + throw NetProbeException("netprobe: body_check_max_bytes must be greater than 0"); + } + _http_opts.body_check_max_bytes = static_cast(n); + } if (config_exists("proxy")) { _http_opts.proxy = scalar_config_to_string(*this, "proxy", "config"); } @@ -352,7 +359,7 @@ void NetProbeInputStream::start() // http-only keys: check each individually so the thrown message names the offending key. { static const std::vector http_only_keys = { - "expected_status", "failure_status", "expected_body", "expected_body_regex", "body"}; + "expected_status", "failure_status", "expected_body", "expected_body_regex", "body", "body_check_max_bytes"}; if (_type != TestType::HTTP) { for (const auto &key : http_only_keys) { if (config_exists(key)) { diff --git a/src/inputs/netprobe/NetProbeInputStream.h b/src/inputs/netprobe/NetProbeInputStream.h index ccaa67f51..80929c038 100644 --- a/src/inputs/netprobe/NetProbeInputStream.h +++ b/src/inputs/netprobe/NetProbeInputStream.h @@ -87,6 +87,7 @@ class NetProbeInputStream : public visor::InputStream "expected_body", "expected_body_regex", "body", + "body_check_max_bytes", "proxy", "tls"}; diff --git a/src/inputs/netprobe/test_netprobe.cpp b/src/inputs/netprobe/test_netprobe.cpp index aebce17f4..535cde9c7 100644 --- a/src/inputs/netprobe/test_netprobe.cpp +++ b/src/inputs/netprobe/test_netprobe.cpp @@ -128,7 +128,7 @@ TEST_CASE("Netprobe invalid config", "[netprobe][config]") NetProbeInputStream stream{"net-probe-test"}; stream.config_set("invalid_config", true); - CHECK_THROWS_WITH(stream.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype, expected_status, failure_status, expected_body, expected_body_regex, body, proxy, tls"); + CHECK_THROWS_WITH(stream.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype, expected_status, failure_status, expected_body, expected_body_regex, body, body_check_max_bytes, proxy, tls"); } TEST_CASE("NetProbe ip_version config", "[netprobe][config][ipv6]") @@ -163,7 +163,7 @@ TEST_CASE("NetProbe ip_version config", "[netprobe][config][ipv6]") SECTION("top-level valid-keys string unchanged") { NetProbeInputStream s{"net-probe-test"}; s.config_set("invalid_config", true); - CHECK_THROWS_WITH(s.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype, expected_status, failure_status, expected_body, expected_body_regex, body, proxy, tls"); + CHECK_THROWS_WITH(s.start(), "invalid_config is an invalid/unsupported config or filter. The valid configs/filters are: test_type, interval_msec, timeout_msec, packets_per_test, packets_interval_msec, packet_payload_size, targets, http_method, qname, qtype, expected_status, failure_status, expected_body, expected_body_regex, body, body_check_max_bytes, proxy, tls"); } } @@ -1385,6 +1385,58 @@ TEST_CASE("NetProbe HTTP e2e v2: expected_body mismatch -> content_failures, nev CHECK(tgt["content_failures"].get() >= 1); } +TEST_CASE("NetProbe HTTP e2e v2: body match past the capture cap is not a false content_failure", "[netprobe][http][e2e]") +{ + // The match text lives after a large padding prefix; with a small body_check_max_bytes the + // captured body is truncated before the marker. The check must NOT be reported as a + // content_failure on a partial body — the sample is classified on status alone (success). + httplib::Server svr; + std::string body = std::string(64 * 1024, 'x') + "MARKER_AT_END"; + svr.Get("/health", [&](const httplib::Request &, httplib::Response &res) { + res.set_content(body, "text/plain"); + }); + int port = svr.bind_to_any_port("127.0.0.1"); + REQUIRE(port > 0); + std::thread server_thread([&svr] { svr.listen_after_bind(); }); + ServerGuard guard{svr, server_thread}; + svr.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port) + "/health"; + + NetProbeInputStream stream{"netprobe-http-e2e-body-truncated"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + stream.config_set("expected_body", std::string("MARKER_AT_END")); + stream.config_set("body_check_max_bytes", 1024); // marker is well beyond this + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + targets->config_set>("health_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-body-truncated", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + json j; + handler.metrics()->bucket(0)->to_json(j); + + REQUIRE(j["targets"].contains("health_target")); + auto &tgt = j["targets"]["health_target"]; + CHECK(tgt["attempts"].get() >= 1); + // Truncated body => body check skipped, classified on status (200) => success, NOT content_failures. + CHECK(tgt["successes"].get() >= 1); + CHECK(tgt["content_failures"].get() == 0); +} + TEST_CASE("NetProbe HTTP e2e v2: POST body is delivered to the server", "[netprobe][http][e2e]") { std::atomic body_seen{false}; From e439a84aac0197ef05ce351361bc687cb63f2208 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:47:52 -0300 Subject: [PATCH 09/14] fix(http): reject oversized status-code entries before narrowing (stoul wrap could accept them as an in-range code) --- libs/visor_http_client/HttpCheck.cpp | 43 +++++++++++----------- libs/visor_http_client/test_http_check.cpp | 4 ++ 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/libs/visor_http_client/HttpCheck.cpp b/libs/visor_http_client/HttpCheck.cpp index 1595c04d9..7dbdf478f 100644 --- a/libs/visor_http_client/HttpCheck.cpp +++ b/libs/visor_http_client/HttpCheck.cpp @@ -14,6 +14,25 @@ static void set_range(std::vector &codes, unsigned lo, unsigned hi, const } } +// Parse a decimal status code from `s`, requiring the whole string to be consumed and the value +// to be a plausible HTTP status (<= 599). Validating the unsigned-long result BEFORE narrowing to +// unsigned is essential: a value that fits in unsigned long but exceeds unsigned (e.g. 4294967496 +// on LP64) would otherwise wrap to a small in-range code and be accepted. `entry` names the +// offending token in the error. +static unsigned parse_status_code(const std::string &s, const std::string &entry) +{ + try { + size_t pos{}; + unsigned long v = std::stoul(s, &pos); + if (pos != s.size() || v > 599) { + throw std::invalid_argument(entry); + } + return static_cast(v); + } catch (const std::exception &) { + throw std::invalid_argument("invalid status entry '" + entry + "'"); + } +} + StatusMatcher StatusMatcher::parse(const std::vector &entries) { StatusMatcher m; @@ -22,29 +41,11 @@ StatusMatcher StatusMatcher::parse(const std::vector &entries) unsigned cls = static_cast(e[0] - '0'); set_range(m._codes, cls * 100, cls * 100 + 99, e); } else if (auto dash = e.find('-'); dash != std::string::npos && dash > 0 && dash < e.size() - 1) { - unsigned lo{}, hi{}; - try { - size_t p1{}, p2{}; - lo = static_cast(std::stoul(e.substr(0, dash), &p1)); - hi = static_cast(std::stoul(e.substr(dash + 1), &p2)); - if (p1 != dash || p2 != e.size() - dash - 1) { - throw std::invalid_argument(e); - } - } catch (const std::exception &) { - throw std::invalid_argument("invalid status entry '" + e + "'"); - } + unsigned lo = parse_status_code(e.substr(0, dash), e); + unsigned hi = parse_status_code(e.substr(dash + 1), e); set_range(m._codes, lo, hi, e); } else { - unsigned code{}; - try { - size_t pos{}; - code = static_cast(std::stoul(e, &pos)); - if (pos != e.size()) { - throw std::invalid_argument(e); - } - } catch (const std::exception &) { - throw std::invalid_argument("invalid status entry '" + e + "'"); - } + unsigned code = parse_status_code(e, e); set_range(m._codes, code, code, e); } m._empty = false; diff --git a/libs/visor_http_client/test_http_check.cpp b/libs/visor_http_client/test_http_check.cpp index e352218cf..5d84fdf03 100644 --- a/libs/visor_http_client/test_http_check.cpp +++ b/libs/visor_http_client/test_http_check.cpp @@ -25,6 +25,10 @@ TEST_CASE("StatusMatcher grammar", "[http][check]") CHECK_THROWS_AS(StatusMatcher::parse({"300-200"}), std::invalid_argument); CHECK_THROWS_AS(StatusMatcher::parse({"6xx"}), std::invalid_argument); CHECK_THROWS_AS(StatusMatcher::parse({""}), std::invalid_argument); + // Oversized values that wrap on a narrowing cast (fit in unsigned long, exceed unsigned) must + // be rejected, not silently accepted as the wrapped-down code (4294967496 mod 2^32 == 200). + CHECK_THROWS_AS(StatusMatcher::parse({"4294967496"}), std::invalid_argument); + CHECK_THROWS_AS(StatusMatcher::parse({"200-4294967496"}), std::invalid_argument); // the error message names the offending entry CHECK_THROWS_WITH(StatusMatcher::parse({"2x"}), Catch::Matchers::ContainsSubstring("2x")); } From 5ee96c8bd358f7227940483045761acabeb48e70 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:01:39 -0300 Subject: [PATCH 10/14] fix(netprobe): keep newest window's tls_cert_expiry on merge, not max (max suppressed alerts after cert downgrade) --- src/handlers/netprobe/NetProbeStreamHandler.cpp | 9 ++++++--- src/handlers/netprobe/test_net_probe.cpp | 8 ++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/handlers/netprobe/NetProbeStreamHandler.cpp b/src/handlers/netprobe/NetProbeStreamHandler.cpp index 3616b6eb2..ab5140e0e 100644 --- a/src/handlers/netprobe/NetProbeStreamHandler.cpp +++ b/src/handlers/netprobe/NetProbeStreamHandler.cpp @@ -151,9 +151,12 @@ void NetProbeMetricsBucket::specialized_merge(const AbstractMetricsBucket &o, Me _targets_metrics[targetId]->top_status_codes.merge(target.second->top_status_codes); _targets_metrics[targetId]->dns_response_failures += target.second->dns_response_failures; _targets_metrics[targetId]->top_rcodes.merge(target.second->top_rcodes); - // Merged windows lose per-sample ordering, so "latest wins" is meaningless here; - // take the max instead, which biases toward the most-recently-renewed certificate. - if (target.second->tls_cert_expiry_epoch > _targets_metrics[targetId]->tls_cert_expiry_epoch) { + // Keep the NEWEST window's cert expiry. Buckets merge newest-first (window_merged_json / + // multiple_merge iterate _metric_buckets, whose front is the live/newest bucket, into a + // fresh accumulator), so the first non-zero value seen is the current certificate. Do NOT + // take the max: a reissue or rollback to a shorter-lived cert must LOWER the reported + // expiry — maxing would keep the old cert's later date and suppress expiry alerts. + if (_targets_metrics[targetId]->tls_cert_expiry_epoch == 0) { _targets_metrics[targetId]->tls_cert_expiry_epoch = target.second->tls_cert_expiry_epoch; } } diff --git a/src/handlers/netprobe/test_net_probe.cpp b/src/handlers/netprobe/test_net_probe.cpp index 3c5de2dea..31b180a81 100644 --- a/src/handlers/netprobe/test_net_probe.cpp +++ b/src/handlers/netprobe/test_net_probe.cpp @@ -715,7 +715,7 @@ TEST_CASE("NetProbe HTTP response_size_bytes: absent when quantiles group not en CHECK(!j["targets"]["no-respsize"].contains("response_size_bytes")); } -TEST_CASE("NetProbe HTTP merge: content_failures sums, tls_cert_expiry_epoch_sec takes max, q_response_size survives merge", "[netprobe][http][unit]") +TEST_CASE("NetProbe HTTP merge: content_failures sums, tls_cert_expiry_epoch_sec keeps the newest window (not max), q_response_size survives merge", "[netprobe][http][unit]") { QuantilesFixture fx_a("netprobe-http-merge-a", 2); QuantilesFixture fx_b("netprobe-http-merge-b", 2); @@ -732,6 +732,10 @@ TEST_CASE("NetProbe HTTP merge: content_failures sums, tls_cert_expiry_epoch_sec return s; }; + // fx_a is merged first (it stands in for the NEWEST window) and carries the SHORTER-lived cert + // (1700000000); fx_b (older) carries a LATER date (1800000000). Newest must win, so the merged + // value is fx_a's — proving we do NOT take the max, which would keep the stale later date and + // suppress expiry alerts after a cert reissue/rollback to a shorter-lived cert. fx_a.manager()->process_netprobe_http_result(make_sample(true, 2, 1700000000, 256), "shared", stamp); fx_a.manager()->process_netprobe_http_result(make_sample(true, 2, 0, 512), "shared", stamp); fx_b.manager()->process_netprobe_http_result(make_sample(true, 2, 1800000000, 1024), "shared", stamp); @@ -745,7 +749,7 @@ TEST_CASE("NetProbe HTTP merge: content_failures sums, tls_cert_expiry_epoch_sec merged->to_json(j); CHECK(j["targets"]["shared"]["content_failures"] == 3); - CHECK(j["targets"]["shared"]["tls_cert_expiry_epoch_sec"] == 1800000000); + CHECK(j["targets"]["shared"]["tls_cert_expiry_epoch_sec"] == 1700000000); REQUIRE(j["targets"]["shared"].contains("response_size_bytes")); } From ce21bf97018042d36f5ab5018000696d20369620 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:01:39 -0300 Subject: [PATCH 11/14] build: bump libcurl 8.20.0->8.21.0, libnghttp2 1.61.0->1.68.1 --- conanfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conanfile.py b/conanfile.py index 048aebd3e..783f94b69 100644 --- a/conanfile.py +++ b/conanfile.py @@ -27,8 +27,8 @@ def requirements(self): self.requires("uvw/3.4.0") self.requires("yaml-cpp/0.9.0") self.requires("robin-hood-hashing/3.11.5") - self.requires("libcurl/8.20.0") - self.requires("libnghttp2/1.61.0") + self.requires("libcurl/8.21.0") + self.requires("libnghttp2/1.68.1") if ( "libc" not in self.settings.compiler.fields or self.settings.compiler.libc != "musl" From c0ec1b0f57887575b1d04cb73a2dbd79045ca7bd Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:41:36 -0300 Subject: [PATCH 12/14] fix(http): redact proxy value + embedded credentials from curl error_msg (probes log it on transport failure) --- libs/visor_http_client/HttpClient.cpp | 38 +++++++++++++++++++++ libs/visor_http_client/HttpClient.h | 1 + libs/visor_http_client/test_http_client.cpp | 28 +++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/libs/visor_http_client/HttpClient.cpp b/libs/visor_http_client/HttpClient.cpp index f1afe159b..293fe9eee 100644 --- a/libs/visor_http_client/HttpClient.cpp +++ b/libs/visor_http_client/HttpClient.cpp @@ -25,6 +25,36 @@ static void ensure_curl_global_init() std::call_once(flag, [] { curl_global_init(CURL_GLOBAL_DEFAULT); }); } +// Replace every occurrence of `secret` in `s` with a placeholder (no-op if secret is empty). +static void redact_secret(std::string &s, const std::string &secret) +{ + if (secret.empty()) { + return; + } + static const std::string rep = ""; + for (size_t pos = s.find(secret); pos != std::string::npos; pos = s.find(secret, pos + rep.size())) { + s.replace(pos, secret.size(), rep); + } +} + +// Extract the "user:pass" userinfo from a proxy URL string, or "" if none. Userinfo is the span +// between an optional "scheme://" and the "@" that terminates the authority's userinfo. +static std::string proxy_userinfo(const std::string &proxy) +{ + size_t start = 0; + if (auto scheme = proxy.find("://"); scheme != std::string::npos) { + start = scheme + 3; + } + auto at = proxy.find('@', start); + if (at == std::string::npos) { + return ""; + } + if (auto slash = proxy.find('/', start); slash != std::string::npos && slash < at) { + return ""; // '@' is past the authority (e.g. in a path) — not userinfo + } + return proxy.substr(start, at - start); +} + std::optional validate_http_url(const std::string &url) { // This may be the FIRST libcurl call (config validation runs before any HttpClient is @@ -186,6 +216,7 @@ void HttpClient::request(const HttpRequest &req, ResultCallback on_done) } if (!req.proxy.empty()) { curl_easy_setopt(easy, CURLOPT_PROXY, req.proxy.c_str()); + ctx->proxy = req.proxy; // retained only to redact it (and any embedded credentials) from error_msg } if (!req.ca_file.empty()) { curl_easy_setopt(easy, CURLOPT_CAINFO, req.ca_file.c_str()); @@ -418,6 +449,13 @@ void HttpClient::check_multi_info() } else { result.error_msg = curl_easy_strerror(msg->data.result); } + // curl error text can echo the proxy URL verbatim (e.g. a malformed proxy) or its + // credentials; the probes log error_msg, so scrub the proxy value + userinfo here — the + // single choke point — to uphold the "proxy value never appears in output" guarantee. + if (it != _easy.end() && !it->second->proxy.empty()) { + redact_secret(result.error_msg, it->second->proxy); + redact_secret(result.error_msg, proxy_userinfo(it->second->proxy)); + } } curl_multi_remove_handle(_multi, easy); curl_easy_cleanup(easy); diff --git a/libs/visor_http_client/HttpClient.h b/libs/visor_http_client/HttpClient.h index e342fdbd5..1ab887a53 100644 --- a/libs/visor_http_client/HttpClient.h +++ b/libs/visor_http_client/HttpClient.h @@ -45,6 +45,7 @@ class HttpClient char errbuf[CURL_ERROR_SIZE]{}; curl_slist *headers{nullptr}; // owned; freed in dtor (after curl_easy_cleanup) bool capture{false}; + std::string proxy; // configured proxy (may embed credentials); used only to REDACT it from error_msg size_t capture_max{64 * 1024}; // cap on captured body bytes (from HttpRequest.capture_max_bytes) bool truncated{false}; // set by write_capture when the body exceeds capture_max std::string response; // captured body (bounded to capture_max) diff --git a/libs/visor_http_client/test_http_client.cpp b/libs/visor_http_client/test_http_client.cpp index c21d8f195..abd7ac088 100644 --- a/libs/visor_http_client/test_http_client.cpp +++ b/libs/visor_http_client/test_http_client.cpp @@ -399,3 +399,31 @@ TEST_CASE("HttpClient body capture cap + truncation flag", "[http][client]") svr.stop(); if (server_thread.joinable()) server_thread.join(); } + +TEST_CASE("HttpClient redacts proxy credentials from transport error_msg", "[http][client]") +{ + auto loop = uvw::loop::create(); + HttpClient client(loop); + std::vector results; + auto on_done = [&](const HttpResult &r) { results.push_back(r); }; + + HttpRequest req; + req.url = "http://example.invalid/"; + // Malformed proxy (space in host) carrying credentials: curl fails and its error text can echo + // the proxy string verbatim. The credential (and the whole proxy value) must not survive into + // error_msg, which the probes log on transport failure. + req.proxy = "http://user:sekrit@bad host:3128"; + req.timeout_ms = 2000; + client.request(req, on_done); + auto wd = arm_watchdog(loop, 5000); + loop->run(); + disarm_watchdog(loop, wd); + + REQUIRE(results.size() == 1); + CHECK_FALSE(results[0].transport_ok); + CHECK(results[0].error_msg.find("sekrit") == std::string::npos); + CHECK(results[0].error_msg.find("user:sekrit") == std::string::npos); + + client.close(); + loop->run(); +} From 3c6976e4d88650865f5687f7e74ed711334ed0b4 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:09:58 -0300 Subject: [PATCH 13/14] fix(netprobe): don't follow redirects when a probe carries custom headers (libcurl re-sends them cross-host, leaking secret headers) --- src/handlers/netprobe/README.md | 2 +- src/inputs/netprobe/HttpProbe.cpp | 6 +++ src/inputs/netprobe/test_netprobe.cpp | 64 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/handlers/netprobe/README.md b/src/handlers/netprobe/README.md index 3033a2551..7d90ce1c8 100644 --- a/src/handlers/netprobe/README.md +++ b/src/handlers/netprobe/README.md @@ -39,7 +39,7 @@ Unlike ping/tcp, HTTP targets are specified as full URLs. | `expected_body_regex` | string | *(unset)* | Regex (ECMAScript syntax) that must match somewhere in the response body. Same AND semantics and `content_failures` accounting as `expected_body`. | | `body_check_max_bytes` | uint64 | 524288 (512 KiB) | Maximum number of response-body bytes captured for `expected_body`/`expected_body_regex` evaluation. If a response body exceeds this, it is truncated and the **body check is skipped** for that sample (classified on status alone, with a warning logged) rather than risk a false `content_failures` on a match that lives past the cap, or an anchored regex matching the truncation boundary. Raise it if your health endpoints return large bodies whose match text is deep in the response. | | `body` | string | *(unset)* | Request body to send. Only valid when `http_method` is `POST`, `PUT`, or `PATCH` — set on any other method throws a config error at start. | -| `targets..headers` | map | *(unset)* | Per-target HTTP headers (name → value), e.g. `Authorization: Bearer `. Sent only on requests to that target. **Redaction note:** header *values* are never echoed back by `info_json`/status output — they're replaced with ``. Header *names* (e.g. `Authorization`) are still surfaced (as `header_names`) since they're useful for debugging and carry no secret. | +| `targets..headers` | map | *(unset)* | Per-target HTTP headers (name → value), e.g. `Authorization: Bearer `. Sent only on requests to that target. **Redaction note:** header *values* are never echoed back by `info_json`/status output — they're replaced with ``. Header *names* (e.g. `Authorization`) are still surfaced (as `header_names`) since they're useful for debugging and carry no secret. **Redirects:** when a target has custom headers, the probe does **not** follow HTTP redirects — libcurl would otherwise re-send the headers to the redirect target (possibly another host), leaking secret headers. A `30x` is then reported as the response status (configure `expected_status` if a redirect should count as success). | | `proxy` | string | *(unset)* | HTTP/HTTPS proxy URL for all targets, e.g. `http://user:pass@proxy.example:3128`. Applies to `http` and `doh` only (not `tcp`/`ping`). Like headers, the configured value is fully redacted (``) wherever config is echoed back — even the host/port, since the whole string can carry embedded credentials. | | `tls.verify` | bool | `true` | Whether to verify the target's TLS certificate/hostname. Set `false` only for testing against self-signed endpoints. | | `tls.ca_file` | string | *(unset)* | Path to a CA bundle to trust in addition to (or instead of) the system store. Must exist at start time or the stream fails to start. | diff --git a/src/inputs/netprobe/HttpProbe.cpp b/src/inputs/netprobe/HttpProbe.cpp index 7b4191e01..1b1b7048b 100644 --- a/src/inputs/netprobe/HttpProbe.cpp +++ b/src/inputs/netprobe/HttpProbe.cpp @@ -30,6 +30,12 @@ bool HttpProbe::start(std::shared_ptr io_loop) req.timeout_ms = _config.timeout_msec; req.body = _opts.request_body; req.headers = _headers; + // libcurl re-sends custom request headers on followed redirects (it only strips a few + // built-ins like Authorization/Cookie), so a 30x to another host would leak an operator's + // secret header (e.g. X-Api-Key). curl has no per-host scoping for arbitrary headers, so + // when this probe carries custom headers we do NOT follow redirects — the 30x is reported + // as the result instead. (DoH's fixed non-secret headers are unaffected.) + req.follow_redirects = _headers.empty(); req.proxy = _opts.proxy; req.ca_file = _opts.ca_file; req.cert_file = _opts.cert_file; diff --git a/src/inputs/netprobe/test_netprobe.cpp b/src/inputs/netprobe/test_netprobe.cpp index 535cde9c7..00e40f179 100644 --- a/src/inputs/netprobe/test_netprobe.cpp +++ b/src/inputs/netprobe/test_netprobe.cpp @@ -1437,6 +1437,70 @@ TEST_CASE("NetProbe HTTP e2e v2: body match past the capture cap is not a false CHECK(tgt["content_failures"].get() == 0); } +TEST_CASE("NetProbe HTTP e2e v2: custom headers are not forwarded across redirects", "[netprobe][http][e2e]") +{ + // Server A redirects to server B; B records whether it ever received the secret header. With a + // custom per-target header configured, the probe must NOT follow the 302, so B is never hit and + // the secret never leaves A (libcurl would otherwise re-send custom headers to the new host). + std::atomic b_saw_header{false}; + std::atomic b_hits{0}; + httplib::Server svr_b; + svr_b.Get("/leak", [&](const httplib::Request &req, httplib::Response &res) { + ++b_hits; + if (req.has_header("X-Api-Key")) { + b_saw_header = true; + } + res.set_content("leaked", "text/plain"); + }); + int port_b = svr_b.bind_to_any_port("127.0.0.1"); + REQUIRE(port_b > 0); + std::thread thread_b([&svr_b] { svr_b.listen_after_bind(); }); + ServerGuard guard_b{svr_b, thread_b}; + svr_b.wait_until_ready(); + + httplib::Server svr_a; + std::string loc = "http://127.0.0.1:" + std::to_string(port_b) + "/leak"; + svr_a.Get("/redirect", [&](const httplib::Request &, httplib::Response &res) { + res.status = 302; + res.set_header("Location", loc); + }); + int port_a = svr_a.bind_to_any_port("127.0.0.1"); + REQUIRE(port_a > 0); + std::thread thread_a([&svr_a] { svr_a.listen_after_bind(); }); + ServerGuard guard_a{svr_a, thread_a}; + svr_a.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port_a) + "/redirect"; + NetProbeInputStream stream{"netprobe-http-e2e-redirect-headers"}; + stream.config_set("test_type", "http"); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + auto headers = std::make_shared(); + headers->config_set("X-Api-Key", std::string("sekrit")); + target->config_set>("headers", headers); + targets->config_set>("redir_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-redirect-headers", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + // The redirect must not have been followed: server B was never contacted, so the secret header + // never left server A. + CHECK(b_hits.load() == 0); + CHECK(b_saw_header.load() == false); +} + TEST_CASE("NetProbe HTTP e2e v2: POST body is delivered to the server", "[netprobe][http][e2e]") { std::atomic body_seen{false}; From 139232db464fd5497e517bf9018148e22f329063 Mon Sep 17 00:00:00 2001 From: Leo Parente <23251360+leoparente@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:20:45 -0300 Subject: [PATCH 14/14] fix(netprobe): also disable redirects for body-bearing probes (307/308 resends the payload to the redirect target) --- src/handlers/netprobe/README.md | 2 +- src/inputs/netprobe/HttpProbe.cpp | 15 ++++--- src/inputs/netprobe/test_netprobe.cpp | 58 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/handlers/netprobe/README.md b/src/handlers/netprobe/README.md index 7d90ce1c8..b8678a000 100644 --- a/src/handlers/netprobe/README.md +++ b/src/handlers/netprobe/README.md @@ -38,7 +38,7 @@ Unlike ping/tcp, HTTP targets are specified as full URLs. | `expected_body` | string | *(unset)* | Substring that must appear in the response body for the probe to succeed. Only evaluated when the HTTP status already passed (`expected_status`/`failure_status`/default). Combines with `expected_body_regex` using **AND** — both must match if both are set. A body-check failure on an otherwise-successful status is counted as `content_failures`, not `http_status_failures`. | | `expected_body_regex` | string | *(unset)* | Regex (ECMAScript syntax) that must match somewhere in the response body. Same AND semantics and `content_failures` accounting as `expected_body`. | | `body_check_max_bytes` | uint64 | 524288 (512 KiB) | Maximum number of response-body bytes captured for `expected_body`/`expected_body_regex` evaluation. If a response body exceeds this, it is truncated and the **body check is skipped** for that sample (classified on status alone, with a warning logged) rather than risk a false `content_failures` on a match that lives past the cap, or an anchored regex matching the truncation boundary. Raise it if your health endpoints return large bodies whose match text is deep in the response. | -| `body` | string | *(unset)* | Request body to send. Only valid when `http_method` is `POST`, `PUT`, or `PATCH` — set on any other method throws a config error at start. | +| `body` | string | *(unset)* | Request body to send. Only valid when `http_method` is `POST`, `PUT`, or `PATCH` — set on any other method throws a config error at start. Like custom headers, a body-bearing probe does **not** follow redirects (a `307`/`308` preserves the method and body, which would resend the payload to the redirect target). | | `targets..headers` | map | *(unset)* | Per-target HTTP headers (name → value), e.g. `Authorization: Bearer `. Sent only on requests to that target. **Redaction note:** header *values* are never echoed back by `info_json`/status output — they're replaced with ``. Header *names* (e.g. `Authorization`) are still surfaced (as `header_names`) since they're useful for debugging and carry no secret. **Redirects:** when a target has custom headers, the probe does **not** follow HTTP redirects — libcurl would otherwise re-send the headers to the redirect target (possibly another host), leaking secret headers. A `30x` is then reported as the response status (configure `expected_status` if a redirect should count as success). | | `proxy` | string | *(unset)* | HTTP/HTTPS proxy URL for all targets, e.g. `http://user:pass@proxy.example:3128`. Applies to `http` and `doh` only (not `tcp`/`ping`). Like headers, the configured value is fully redacted (``) wherever config is echoed back — even the host/port, since the whole string can carry embedded credentials. | | `tls.verify` | bool | `true` | Whether to verify the target's TLS certificate/hostname. Set `false` only for testing against self-signed endpoints. | diff --git a/src/inputs/netprobe/HttpProbe.cpp b/src/inputs/netprobe/HttpProbe.cpp index 1b1b7048b..16a9cb7b8 100644 --- a/src/inputs/netprobe/HttpProbe.cpp +++ b/src/inputs/netprobe/HttpProbe.cpp @@ -30,12 +30,15 @@ bool HttpProbe::start(std::shared_ptr io_loop) req.timeout_ms = _config.timeout_msec; req.body = _opts.request_body; req.headers = _headers; - // libcurl re-sends custom request headers on followed redirects (it only strips a few - // built-ins like Authorization/Cookie), so a 30x to another host would leak an operator's - // secret header (e.g. X-Api-Key). curl has no per-host scoping for arbitrary headers, so - // when this probe carries custom headers we do NOT follow redirects — the 30x is reported - // as the result instead. (DoH's fixed non-secret headers are unaffected.) - req.follow_redirects = _headers.empty(); + // Do NOT follow redirects when the probe carries potentially-secret payload: + // - custom request headers: libcurl re-sends them on followed redirects (it only strips a + // few built-ins like Authorization/Cookie) with no per-host scoping, so a 30x to another + // host would leak an operator's secret header (e.g. X-Api-Key); + // - a request body: a 307/308 preserves the method and body, re-sending the (redacted, + // potentially secret) payload to the redirect target. + // In either case the 30x is reported as the result instead. (DoH is unaffected — its only + // headers are the fixed, non-secret Content-Type/Accept, and it sends no operator body.) + req.follow_redirects = _headers.empty() && _opts.request_body.empty(); req.proxy = _opts.proxy; req.ca_file = _opts.ca_file; req.cert_file = _opts.cert_file; diff --git a/src/inputs/netprobe/test_netprobe.cpp b/src/inputs/netprobe/test_netprobe.cpp index 00e40f179..184d8ffc7 100644 --- a/src/inputs/netprobe/test_netprobe.cpp +++ b/src/inputs/netprobe/test_netprobe.cpp @@ -1501,6 +1501,64 @@ TEST_CASE("NetProbe HTTP e2e v2: custom headers are not forwarded across redirec CHECK(b_saw_header.load() == false); } +TEST_CASE("NetProbe HTTP e2e v2: request body is not resent across redirects", "[netprobe][http][e2e]") +{ + // Body-bearing probe with NO custom headers (isolates the body gate). Server A replies 308 + // (preserves method + body); server B records whether it was ever hit. The probe must not + // follow, so the secret payload never leaves A. + std::atomic b_hits{0}; + httplib::Server svr_b; + auto record = [&](const httplib::Request &, httplib::Response &res) { + ++b_hits; + res.set_content("ok", "text/plain"); + }; + svr_b.Post("/leak", record); + svr_b.Get("/leak", record); // also count a method-degraded follow + int port_b = svr_b.bind_to_any_port("127.0.0.1"); + REQUIRE(port_b > 0); + std::thread thread_b([&svr_b] { svr_b.listen_after_bind(); }); + ServerGuard guard_b{svr_b, thread_b}; + svr_b.wait_until_ready(); + + httplib::Server svr_a; + std::string loc = "http://127.0.0.1:" + std::to_string(port_b) + "/leak"; + svr_a.Post("/redirect", [&](const httplib::Request &, httplib::Response &res) { + res.status = 308; // 308 preserves the method and body across the redirect + res.set_header("Location", loc); + }); + int port_a = svr_a.bind_to_any_port("127.0.0.1"); + REQUIRE(port_a > 0); + std::thread thread_a([&svr_a] { svr_a.listen_after_bind(); }); + ServerGuard guard_a{svr_a, thread_a}; + svr_a.wait_until_ready(); + + std::string url = "http://127.0.0.1:" + std::to_string(port_a) + "/redirect"; + NetProbeInputStream stream{"netprobe-http-e2e-redirect-body"}; + stream.config_set("test_type", "http"); + stream.config_set("http_method", std::string("POST")); + stream.config_set("body", std::string("{\"secret\":\"sekrit\"}")); + stream.config_set("interval_msec", 200); + stream.config_set("timeout_msec", 150); + auto targets = std::make_shared(); + auto target = std::make_shared(); + target->config_set("target", url); + targets->config_set>("redir_target", target); + stream.config_set>("targets", targets); + + visor::Config c; + c.config_set("num_periods", 1); + auto *proxy = stream.add_event_proxy(c); + NetProbeStreamHandler handler{"netprobe-http-e2e-redirect-body", proxy, &c}; + + handler.start(); + stream.start(); + std::this_thread::sleep_for(750ms); + stream.stop(); + handler.stop(); + + CHECK(b_hits.load() == 0); +} + TEST_CASE("NetProbe HTTP e2e v2: POST body is delivered to the server", "[netprobe][http][e2e]") { std::atomic body_seen{false};