Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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);
}
61 changes: 61 additions & 0 deletions libs/visor_http_client/HttpClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,44 @@ 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;
constexpr size_t kMaxHeaders = 100;
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) {
ctx->resp_headers.clear();
ctx->resp_headers_bytes = 0;
Comment thread
leoparente marked this conversation as resolved.
return n;
}
auto colon = line.find(':');
if (colon != std::string::npos && ctx->resp_headers.size() < kMaxHeaders
&& ctx->resp_headers_bytes + n <= kMaxHeaderBytes) {
Comment thread
leoparente marked this conversation as resolved.
Outdated
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 +268,23 @@ 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()) {
for (const auto &e : req.resolve) {
struct curl_slist *appended = curl_slist_append(ctx->resolve_list, e.c_str());
if (!appended) { /* OOM: leave list intact; skip (best-effort) */ break; }
ctx->resolve_list = appended;
}
if (ctx->resolve_list) curl_easy_setopt(easy, CURLOPT_RESOLVE, ctx->resolve_list);
Comment thread
leoparente marked this conversation as resolved.
Outdated
}
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 +494,12 @@ 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);
}
} else {
result.transport_ok = false;
result.curl_code = msg->data.result;
Expand Down
11 changes: 10 additions & 1 deletion libs/visor_http_client/HttpClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ class HttpClient
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); }
bool collect_headers{false};
std::vector<std::pair<std::string, std::string>> resp_headers;
size_t resp_headers_bytes{0};
curl_slist *resolve_list{nullptr}; // owned; freed in dtor after curl_easy_cleanup
~EasyContext()
{
if (headers) curl_slist_free_all(headers);
if (resolve_list) curl_slist_free_all(resolve_list);
}
};
// per-socket context: a uvw poll handle curl watches (owned in _sockets below)
struct SocketContext {
Expand All @@ -61,6 +69,7 @@ class HttpClient
static int timer_cb(CURLM *multi, long timeout_ms, void *userp);
static size_t write_discard(char *ptr, size_t size, size_t nmemb, void *userdata);
static size_t write_capture(char *ptr, size_t size, size_t nmemb, void *userdata);
static size_t header_capture(char *buffer, size_t size, size_t nitems, void *userdata);
void on_socket_event(curl_socket_t sockfd, int events);
void on_timeout();
void check_multi_info();
Expand Down
6 changes: 6 additions & 0 deletions libs/visor_http_client/HttpTypes.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>

namespace visor::http {
Expand Down Expand Up @@ -28,6 +29,9 @@ struct HttpRequest {
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)
bool collect_headers{false}; // when true, capture the FINAL response's headers into HttpResult.headers
long ip_resolve{0}; // CURLOPT_IPRESOLVE (0 => curl default/whatever; e.g. CURL_IPRESOLVE_V4/V6)
std::vector<std::string> resolve; // CURLOPT_RESOLVE entries, each "host:port:address"
};
struct HttpResult {
bool transport_ok{false};
Expand All @@ -40,6 +44,8 @@ struct HttpResult {
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
std::vector<std::pair<std::string, std::string>> headers; // FINAL response's headers when HttpRequest.collect_headers (redirect/proxy-CONNECT hops excluded)
long http_version{0}; // CURLINFO_HTTP_VERSION (e.g. CURL_HTTP_VERSION_1_1/2_0); populated on every transport_ok
};
struct HttpSample {
uint16_t status{0};
Expand Down
Loading
Loading