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" 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..7dbdf478f --- /dev/null +++ b/libs/visor_http_client/HttpCheck.cpp @@ -0,0 +1,103 @@ +#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; + } +} + +// 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; + 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 = 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 = parse_status_code(e, 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/HttpClient.cpp b/libs/visor_http_client/HttpClient.cpp index bb75528b6..293fe9eee 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 @@ -23,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 @@ -129,9 +161,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 @@ -174,6 +211,25 @@ 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()); + 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()); + } + 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())); @@ -199,6 +255,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()); @@ -353,8 +410,29 @@ 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); + result.body_truncated = it->second->truncated; } char *ct = nullptr; curl_easy_getinfo(easy, CURLINFO_CONTENT_TYPE, &ct); // may be null (no Content-Type) @@ -371,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 9fb691c28..1ab887a53 100644 --- a/libs/visor_http_client/HttpClient.h +++ b/libs/visor_http_client/HttpClient.h @@ -45,7 +45,10 @@ 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) + 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) ~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 797f29e70..5f8a866a2 100644 --- a/libs/visor_http_client/HttpTypes.h +++ b/libs/visor_http_client/HttpTypes.h @@ -21,14 +21,32 @@ 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) + 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}; 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 + 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/libs/visor_http_client/test_http_check.cpp b/libs/visor_http_client/test_http_check.cpp new file mode 100644 index 000000000..5d84fdf03 --- /dev/null +++ b/libs/visor_http_client/test_http_check.cpp @@ -0,0 +1,65 @@ +#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); + // 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")); +} + +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); +} diff --git a/libs/visor_http_client/test_http_client.cpp b/libs/visor_http_client/test_http_client.cpp index 62365f11f..abd7ac088 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(); +} + +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(); +} + +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(); +} 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/NetProbeStreamHandler.cpp b/src/handlers/netprobe/NetProbeStreamHandler.cpp index e11b13d0b..ab5140e0e 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) @@ -147,15 +147,25 @@ 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); + // 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; + } } 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 +192,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 +236,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 +269,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 +313,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 +345,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 +389,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 &) { } } @@ -528,7 +559,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,35 +572,41 @@ 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.successes; - } else { + t.top_status_codes.update(std::to_string(sample.status)); + 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 } } // 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); + t.q_response_size.update(sample.response_size); } 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 +618,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, uint64_t cert_expiry_epoch, const visor::http::HttpTimings &timings, const std::string &target) { std::unique_lock lock(_mutex); @@ -591,6 +628,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) { @@ -624,11 +664,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..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") { } }; @@ -132,8 +139,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 +171,9 @@ class NetProbeMetricsManager final : public visor::AbstractMetricsManager.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_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. 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. | +| `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 +75,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 +148,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 +170,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 response body size in bytes (`http` test type only) | ### HTTP response phases (group: `http_response_phases`, opt-in) @@ -193,3 +219,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/handlers/netprobe/test_net_probe.cpp b/src/handlers/netprobe/test_net_probe.cpp index be02f5a3a..31b180a81 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]") @@ -425,8 +451,16 @@ TEST_CASE("NetProbe HTTP status-aware metrics: counters and top_status_codes", " auto timings = visor::http::HttpTimings{/*total_us*/ 1234, /*dns_us*/ 100, /*connect_us*/ 200, /*tls_us*/ 300, /*ttfb_us*/ 400}; // 200 → success; 404 → http_status_failures; ConnectFailure → connect_failures - fx.manager()->process_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 +499,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 +534,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 +559,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 +582,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)); @@ -552,6 +608,151 @@ 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 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); + + 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 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); + + 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"] == 1700000000); + REQUIRE(j["targets"]["shared"].contains("response_size_bytes")); +} + TEST_CASE("NetProbe DoH DNS-aware metrics: counters and top_rcodes", "[netprobe][doh][unit]") { UnitFixture fx; @@ -560,13 +761,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"); @@ -611,6 +812,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; @@ -625,7 +840,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 +872,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 +891,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..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.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 144aad0b2..ac9c2a601 100644 --- a/src/inputs/netprobe/DohProbe.h +++ b/src/inputs/netprobe/DohProbe.h @@ -4,14 +4,16 @@ #pragma once #include "HttpClient.h" +#include "HttpProbeOptions.h" #include "NetProbe.h" #include +#include #include 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 { @@ -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 f055933a1..16a9cb7b8 100644 --- a/src/inputs/netprobe/HttpProbe.cpp +++ b/src/inputs/netprobe/HttpProbe.cpp @@ -28,14 +28,70 @@ 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; + // 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; + 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(); + req.capture_max_bytes = _opts.body_check_max_bytes; 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) { - http_result(static_cast(r.status_code), r.timings, name, stamp); + visor::http::HttpSample s; + s.status = static_cast(r.status_code); + 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; + if (status_ok && opts.body_check.configured()) { + 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 + // 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); } 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..96cda61b9 100644 --- a/src/inputs/netprobe/HttpProbe.h +++ b/src/inputs/netprobe/HttpProbe.h @@ -4,30 +4,42 @@ #pragma once #include "HttpClient.h" +#include "HttpProbeOptions.h" #include "NetProbe.h" #include +#include #include namespace visor::input::netprobe { -using HttpResultCallback = std::function; +using HttpResultCallback = std::function; 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..c2bbd6126 --- /dev/null +++ b/src/inputs/netprobe/HttpProbeOptions.h @@ -0,0 +1,25 @@ +/* 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 + 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}; + std::string ca_file, cert_file, key_file; + std::string user_agent; // "pktvisor/" VISOR_VERSION_NUM +}; +} 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 a79654dd0..f13282b1f 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,66 @@ 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"; +} + +// 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. +// `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 cfg.config_get(key); + } catch (const visor::ConfigException &) { + } + try { + return std::to_string(cfg.config_get(key)); + } catch (const visor::ConfigException &) { + } + try { + return cfg.config_get(key) ? "true" : "false"; + } catch (const visor::ConfigException &) { + } + 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. +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 +192,62 @@ 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())); + } + } + { + // 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) { + throw NetProbeException(fmt::format("netprobe: {}", e.what())); + } + } + 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"); + } + 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 +264,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 +283,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 +355,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", "body_check_max_bytes"}; + 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; @@ -250,19 +444,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); } } @@ -363,8 +557,12 @@ 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); }); + 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); probe->set_callbacks([this](pcpp::Packet &payload, TestType type, const std::string &name, timespec stamp) { _send_cb(payload, type, name, stamp); }, @@ -375,9 +573,9 @@ 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); + 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); }); ++_id; probe->set_configs(_interval_msec, _timeout_msec, _packets_per_test, _packets_interval_msec, _packet_payload_size); @@ -426,9 +624,48 @@ 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 the secret-bearing values before this JSON goes anywhere. + if (j.contains("module") && j["module"].contains("config")) { + auto &cfg = j["module"]["config"]; + scrub_netprobe_config_json(cfg); + if (cfg.contains("targets") && cfg["targets"].is_object()) { + // 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 62c24d7c8..80929c038 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" @@ -23,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; @@ -39,6 +45,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,14 +81,22 @@ class NetProbeInputStream : public visor::InputStream "targets", "http_method", "qname", - "qtype"}; + "qtype", + "expected_status", + "failure_status", + "expected_body", + "expected_body_regex", + "body", + "body_check_max_bytes", + "proxy", + "tls"}; void _create_netprobe_loop(); 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 +143,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 +159,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; }; } diff --git a/src/inputs/netprobe/test_netprobe.cpp b/src/inputs/netprobe/test_netprobe.cpp index 8f6625a99..184d8ffc7 100644 --- a/src/inputs/netprobe/test_netprobe.cpp +++ b/src/inputs/netprobe/test_netprobe.cpp @@ -4,8 +4,12 @@ #include #include +#include #include +#include +#include #include +#include #include #include #ifdef __GNUC__ @@ -124,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"); + 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]") @@ -159,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"); + 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"); } } @@ -264,6 +268,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 +555,106 @@ 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 + // 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. @@ -830,3 +1117,502 @@ 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: 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: 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: 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}; + 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); +}