Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion libs/visor_http_client/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ find_package(CURL REQUIRED)
find_package(uvw REQUIRED)
find_package(httplib REQUIRED)
find_package(Catch2 REQUIRED)
find_package(nlohmann_json REQUIRED)

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)
target_include_directories(VisorHttpClient PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(VisorHttpClient PUBLIC CURL::libcurl uvw::uvw)
target_link_libraries(VisorHttpClient PUBLIC CURL::libcurl uvw::uvw PRIVATE nlohmann_json::nlohmann_json)

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)
Expand Down
162 changes: 161 additions & 1 deletion libs/visor_http_client/HttpCheck.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#include "HttpCheck.h"
#include <curl/curl.h> // curl_getdate (cpp only — the header stays curl-free)
#include <algorithm>
#include <cctype>
#include <curl/curl.h> // curl_getdate, CURL_HTTP_VERSION_* (cpp only — the header stays curl-free)
#include <nlohmann/json.hpp> // JSON pointer parsing (cpp only — the header stays nlohmann-free)
#include <stdexcept>

namespace visor::http {
Expand Down Expand Up @@ -100,4 +103,161 @@ uint64_t parse_cert_expire_date(const std::string &date_str)
time_t t = curl_getdate(date_str.c_str(), nullptr);
return t > 0 ? static_cast<uint64_t>(t) : 0;
}

JsonPointerCheck JsonPointerCheck::compile(const std::string &ptr, const std::string &equals, bool equals_set)
{
JsonPointerCheck c;
try {
(void)nlohmann::json::json_pointer(ptr); // validate RFC 6901 syntax
} catch (const std::exception &) {
throw std::invalid_argument("json_path is not a valid JSON Pointer (RFC 6901): '" + ptr + "'");
}
c._pointer = ptr;
c._has_expected = equals_set;
c._expected = equals;
c._configured = true;
return c;
}

bool JsonPointerCheck::configured() const
{
return _configured;
}

bool JsonPointerCheck::matches(const std::string &body) const
{
nlohmann::json doc = nlohmann::json::parse(body, nullptr, false); // no exceptions
if (doc.is_discarded()) {
return false; // not valid JSON
}
nlohmann::json::json_pointer p(_pointer);
try {
if (!doc.contains(p)) {
return false; // pointer does not resolve
}
if (!_has_expected) {
return true; // presence-only
}
const nlohmann::json &v = doc.at(p);
std::string actual = v.is_string() ? v.get<std::string>() : v.dump(); // compact text for non-strings
return actual == _expected;
} catch (const nlohmann::json::exception &) {
// defensive: nlohmann 3.12's contains()/at() did not throw on a deeply-missing parent in
// observed testing, but guard against it anyway since it is not guaranteed by the API.
return false;
}
}

BodyNegativeCheck BodyNegativeCheck::compile(const std::string &not_substr, const std::string &not_regex_pattern)
{
BodyNegativeCheck c;
c.not_substring = not_substr;
if (!not_regex_pattern.empty()) {
try {
c.not_regex.emplace(not_regex_pattern, std::regex::ECMAScript);
} catch (const std::regex_error &) {
// never quote the pattern — it can embed secrets
throw std::invalid_argument("body_not_matches_regex is not a valid ECMAScript regular expression");
}
}
return c;
}

bool BodyNegativeCheck::matches(const std::string &body) const
{
if (!not_substring.empty() && body.find(not_substring) != std::string::npos) {
return false;
}
if (not_regex.has_value() && std::regex_search(body, *not_regex)) {
return false;
}
return true;
}

bool iequals_ascii(const std::string &a, const std::string &b)
{
if (a.size() != b.size()) {
return false;
}
for (size_t i = 0; i < a.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(a[i])) != std::tolower(static_cast<unsigned char>(b[i]))) {
return false;
}
}
return true;
}

HeaderMatchers HeaderMatchers::compile(const std::vector<std::pair<std::string, std::string>> &fail_if_matches,
const std::vector<std::pair<std::string, std::string>> &fail_if_not_matches)
{
HeaderMatchers m;
auto build = [](const std::vector<std::pair<std::string, std::string>> &src, std::vector<HeaderMatcher> &dst) {
for (const auto &[name, pat] : src) {
try {
dst.push_back(HeaderMatcher{name, std::regex(pat, std::regex::ECMAScript)});
} catch (const std::regex_error &) {
// never quote the pattern — it can embed secrets; name the header instead
throw std::invalid_argument("header value_regex is not a valid ECMAScript regular expression (header '" + name + "')");
}
}
};
build(fail_if_matches, m._fail_if_matches);
build(fail_if_not_matches, m._fail_if_not_matches);
m._configured = !m._fail_if_matches.empty() || !m._fail_if_not_matches.empty();
return m;
}

bool HeaderMatchers::configured() const
{
return _configured;
}

bool HeaderMatchers::matches(const std::vector<std::pair<std::string, std::string>> &headers) const
{
for (const auto &hm : _fail_if_matches) {
for (const auto &[hn, hv] : headers) {
if (iequals_ascii(hn, hm.name) && std::regex_search(hv, hm.value_regex)) {
return false; // a forbidden header matched
}
}
}
for (const auto &hm : _fail_if_not_matches) {
bool any = false;
for (const auto &[hn, hv] : headers) {
if (iequals_ascii(hn, hm.name) && std::regex_search(hv, hm.value_regex)) {
any = true;
break;
}
}
if (!any) {
return false; // required header/value not present
}
}
return true;
}

uint64_t parse_http_date(const std::string &date_str)
{
if (date_str.empty()) {
return 0;
}
time_t t = curl_getdate(date_str.c_str(), nullptr);
return t > 0 ? static_cast<uint64_t>(t) : 0;
}

std::string http_version_name(long v)
{
switch (v) {
case CURL_HTTP_VERSION_1_0:
return "1.0";
case CURL_HTTP_VERSION_1_1:
return "1.1";
case CURL_HTTP_VERSION_2_0:
return "2";
case CURL_HTTP_VERSION_3:
return "3";
default:
return "";
}
}
}
60 changes: 60 additions & 0 deletions libs/visor_http_client/HttpCheck.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <optional>
#include <regex>
#include <string>
#include <utility>
#include <vector>

namespace visor::http {
Expand Down Expand Up @@ -35,4 +36,63 @@ struct BodyCheck {
// 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);

// RFC 6901 JSON Pointer assertion over a JSON body.
class JsonPointerCheck
{
public:
JsonPointerCheck() = default; // not configured; configured()==false
// ptr: RFC 6901 pointer (validated; throws std::invalid_argument if malformed).
// equals_set=false => presence-only (pointer must resolve). equals_set=true => value's
// string form must equal `equals`.
static JsonPointerCheck compile(const std::string &ptr, const std::string &equals, bool equals_set);
bool configured() const;
bool matches(const std::string &body) const; // true = PASS
private:
std::string _pointer;
bool _has_expected{false};
std::string _expected;
bool _configured{false};
};

// Inverse body assertions: body must NOT contain `substring` and must NOT match `regex`.
struct BodyNegativeCheck {
std::string not_substring; // empty => not checked
std::optional<std::regex> not_regex; // nullopt => not checked
bool configured() const { return !not_substring.empty() || not_regex.has_value(); }
static BodyNegativeCheck compile(const std::string &not_substr, const std::string &not_regex_pattern);
bool matches(const std::string &body) const; // true = PASS (neither negative hit)
};

// Response-header assertions (fail_if_header_matches / fail_if_header_not_matches).
struct HeaderMatcher {
std::string name; // case-insensitive header name
std::regex value_regex; // compiled ECMAScript
};
class HeaderMatchers
{
public:
// Each pair is (name, value_regex_pattern). compile throws std::invalid_argument on a bad
// pattern (never quoting it). fail_if_matches: PASS unless some header `name` value matches.
// fail_if_not_matches: PASS only if some header `name` value matches.
static HeaderMatchers compile(const std::vector<std::pair<std::string, std::string>> &fail_if_matches,
const std::vector<std::pair<std::string, std::string>> &fail_if_not_matches);
bool configured() const;
// headers: response headers as (name,value); name compared case-insensitively.
bool matches(const std::vector<std::pair<std::string, std::string>> &headers) const; // true = PASS
private:
std::vector<HeaderMatcher> _fail_if_matches;
std::vector<HeaderMatcher> _fail_if_not_matches;
bool _configured{false};
};

// Parse an HTTP-date (Last-Modified) to unix epoch via curl_getdate; 0 on failure/empty.
uint64_t parse_http_date(const std::string &date_str);

// Map CURLINFO_HTTP_VERSION (CURL_HTTP_VERSION_*) to "1.0"/"1.1"/"2"/"3"/"" (unknown).
std::string http_version_name(long curl_http_version);

// Portable ASCII case-insensitive equality (no strcasecmp — MSVC). Used for header-name matching
// here and by the probe's Last-Modified lookup (Task 5).
bool iequals_ascii(const std::string &a, const std::string &b);
}
100 changes: 100 additions & 0 deletions libs/visor_http_client/HttpClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ std::optional<std::string> validate_http_url(const std::string &url)
return std::nullopt;
}

std::string build_connect_to_entry(const std::string &resolve_entry)
{
// Parse "host:port:address" (address may itself contain ':' when IPv6, so the address is
// everything after the SECOND colon — the two leading colons delimit host and port).
auto c1 = resolve_entry.find(':');
auto c2 = (c1 == std::string::npos) ? std::string::npos : resolve_entry.find(':', c1 + 1);
if (c1 == std::string::npos || c2 == std::string::npos || c1 == 0 || c2 == c1 + 1 || c2 + 1 >= resolve_entry.size()) {
return {}; // malformed: missing a colon or an empty host/port/address field
}
std::string host = resolve_entry.substr(0, c1);
std::string port = resolve_entry.substr(c1 + 1, c2 - c1 - 1);
std::string address = resolve_entry.substr(c2 + 1);
// curl's --connect-to HOST2 needs an IPv6 literal bracketed; bracket it if the operator didn't.
if (address.front() != '[' && address.find(':') != std::string::npos) {
address = "[" + address + "]";
}
return host + ":" + port + ":" + address + ":" + port;
}

HttpClient::HttpClient(std::shared_ptr<uvw::loop> loop)
: _loop(std::move(loop))
{
Expand Down Expand Up @@ -174,6 +193,51 @@ size_t HttpClient::write_capture(char *ptr, size_t size, size_t nmemb, void *use
return n; // always consume so curl doesn't abort the transfer
}

size_t HttpClient::header_capture(char *buffer, size_t size, size_t nitems, void *userdata)
{
size_t n = size * nitems;
auto *ctx = static_cast<EasyContext *>(userdata);
if (ctx && ctx->collect_headers) {
constexpr size_t kMaxHeaderBytes = 64 * 1024; // byte cap only (DoS guard); no header-count cap
std::string line(buffer, n);
// curl fires this callback for EVERY response in the transfer (each redirect hop, and any
// proxy CONNECT response). A new response begins with a status line "HTTP/..." (no colon).
// Reset so only the FINAL response's headers survive — header/Last-Modified assertions must
// evaluate the final response, matching blackbox/cloudprober semantics.
if (line.rfind("HTTP/", 0) == 0) {
// Reset ALL per-response capture state (including the truncation flag) so a redirect hop
// with oversized headers does not fail the assertion on a small FINAL response.
ctx->resp_headers.clear();
ctx->resp_headers_bytes = 0;
Comment thread
leoparente marked this conversation as resolved.
ctx->headers_truncated = false;
return n;
}
auto colon = line.find(':');
if (colon != std::string::npos) {
if (ctx->resp_headers_bytes + n > kMaxHeaderBytes) {
// Dropping a header line: header-based assertions can no longer be fully verified.
// Flag it so the caller fails conservatively rather than matching a partial set.
ctx->headers_truncated = true;
} else {
std::string name = line.substr(0, colon);
std::string value = line.substr(colon + 1);
auto trim = [](std::string &s) {
size_t b = s.find_first_not_of(" \t\r\n");
size_t e = s.find_last_not_of(" \t\r\n");
s = (b == std::string::npos) ? std::string() : s.substr(b, e - b + 1);
};
trim(name);
trim(value);
if (!name.empty()) {
ctx->resp_headers.emplace_back(std::move(name), std::move(value));
ctx->resp_headers_bytes += n;
}
}
}
}
return n; // always consume
}

void HttpClient::request(const HttpRequest &req, ResultCallback on_done)
{
if (_closed || !_multi) {
Expand Down Expand Up @@ -230,6 +294,35 @@ void HttpClient::request(const HttpRequest &req, ResultCallback on_done)
if (req.collect_cert_info) {
curl_easy_setopt(easy, CURLOPT_CERTINFO, 1L);
}
ctx->collect_headers = req.collect_headers;
if (req.collect_headers) {
curl_easy_setopt(easy, CURLOPT_HEADERFUNCTION, &HttpClient::header_capture);
curl_easy_setopt(easy, CURLOPT_HEADERDATA, ctx.get());
curl_easy_setopt(easy, CURLOPT_SUPPRESS_CONNECT_HEADERS, 1L); // don't feed proxy CONNECT headers to the callback
}
if (req.ip_resolve) {
curl_easy_setopt(easy, CURLOPT_IPRESOLVE, req.ip_resolve);
}
if (!req.resolve.empty()) {
// Apply per-target address overrides via CURLOPT_CONNECT_TO, NOT CURLOPT_RESOLVE. RESOLVE
// entries (without a leading '+') are inserted PERMANENTLY into the DNS cache of the shared
// per-stream multi handle, so a later target for the same host:port that did NOT configure
// an override would still be sent to the pinned address — the override would leak across
// targets. CONNECT_TO is scoped to this easy handle and never touches the DNS cache.
// Each configured entry "host:port:address" becomes CONNECT_TO "host:port:address:port"
// (connect-to-port = the original port; SNI/Host/cert verification keep the original host).
// build_connect_to_entry brackets IPv6 literals and drops malformed entries.
for (const auto &e : req.resolve) {
std::string connect_to = build_connect_to_entry(e);
if (connect_to.empty()) {
continue; // malformed (validated upstream in the netprobe input; defensive here)
}
struct curl_slist *appended = curl_slist_append(ctx->connect_to_list, connect_to.c_str());
if (!appended) { /* OOM: leave list intact; skip (best-effort) */ break; }
ctx->connect_to_list = appended;
}
if (ctx->connect_to_list) curl_easy_setopt(easy, CURLOPT_CONNECT_TO, ctx->connect_to_list);
Comment thread
leoparente marked this conversation as resolved.
}
if (!req.body.empty()) {
// COPYPOSTFIELDS copies the bytes (curl owns them); size set first => binary-safe.
curl_easy_setopt(easy, CURLOPT_POSTFIELDSIZE, static_cast<long>(req.body.size()));
Expand Down Expand Up @@ -439,6 +532,13 @@ void HttpClient::check_multi_info()
if (ct) {
result.content_type = ct; // raw header value; consumers compare case-insensitively
}
long http_ver = 0;
curl_easy_getinfo(easy, CURLINFO_HTTP_VERSION, &http_ver);
result.http_version = http_ver;
if (it != _easy.end() && it->second->collect_headers) {
result.headers = std::move(it->second->resp_headers);
result.headers_truncated = it->second->headers_truncated;
}
} else {
result.transport_ok = false;
result.curl_code = msg->data.result;
Expand Down
Loading
Loading