From 25baab1d656af362957972a3145307b01526259f Mon Sep 17 00:00:00 2001 From: Leonardo Vera Date: Thu, 25 Jun 2026 16:07:04 -0600 Subject: [PATCH 1/5] Add first-seen and last-seen history metadata --- dooked/include/cli_preprocessor.hpp | 6 + dooked/include/utils/io_utils.hpp | 192 +++++++++++++++++++++++++++- dooked/source/cli_preprocessor.cpp | 132 +++++++++++++++++++ dooked/source/main.cpp | 8 ++ dooked/source/utils/io_utils.cpp | 22 ++++ 5 files changed, 358 insertions(+), 2 deletions(-) diff --git a/dooked/include/cli_preprocessor.hpp b/dooked/include/cli_preprocessor.hpp index 43fa1ba..2e3ff59 100644 --- a/dooked/include/cli_preprocessor.hpp +++ b/dooked/include/cli_preprocessor.hpp @@ -24,7 +24,10 @@ struct cli_args_t { int post_http_request{}; int thread_count{}; int content_length{-1}; + int last_seen_days{-1}; + std::string last_seen_date{}; bool include_date{false}; + bool show_first_seen{false}; }; struct runtime_args_t { @@ -36,6 +39,9 @@ struct runtime_args_t { http_process_e http_request_time_{}; int thread_count{}; int content_length{-1}; + int last_seen_days{-1}; + std::string last_seen_date{}; + bool show_first_seen{false}; }; void run_program(cli_args_t const &cli_args); diff --git a/dooked/include/utils/io_utils.hpp b/dooked/include/utils/io_utils.hpp index 829b09e..0197b49 100644 --- a/dooked/include/utils/io_utils.hpp +++ b/dooked/include/utils/io_utils.hpp @@ -2,12 +2,18 @@ #include "utils/containers.hpp" #include "utils/probe_result.hpp" +#include +#include +#include #include #include #include +#include #include #include +#include #include +#include namespace dooked { @@ -20,12 +26,16 @@ bool is_text_file(std::string const &file_extension); bool is_json_file(std::string const &file_extension); std::string get_file_type(std::filesystem::path const &file_path); std::string get_filepath(std::string const &filename); +std::string current_us_datetime(); std::uint16_t uint16_value(unsigned char const *buff); void trim(std::string &); struct json_data_t { std::string domain_name{}; std::string rdata{}; + std::string first_seen{}; + std::string last_seen{}; + int seen{}; int ttl{}; int http_code{}; int content_length{}; @@ -39,9 +49,35 @@ struct json_data_t { data.type = dns_str_to_record_type(json_object["type"].get()); data.rdata = json_object["info"].get(); - data.ttl = json_object["ttl"].get(); + data.ttl = + static_cast(json_object["ttl"].get()); data.content_length = len; data.http_code = http_code; + + auto const read_optional_string = [&json_object](char const *key) { + auto const iter = json_object.find(key); + if (iter != json_object.cend() && iter->second.is_string()) { + return iter->second.get(); + } + return std::string{}; + }; + auto const read_optional_int = [&json_object](char const *key) { + auto const iter = json_object.find(key); + if (iter != json_object.cend() && iter->second.is_number_integer()) { + return iter->second.get(); + } + return json::number_integer_t{}; + }; + + data.first_seen = read_optional_string("first-seen"); + if (data.first_seen.empty()) { + data.first_seen = read_optional_string("first_seen"); + } + data.last_seen = read_optional_string("last-seen"); + if (data.last_seen.empty()) { + data.last_seen = read_optional_string("last_seen"); + } + data.seen = static_cast(read_optional_int("seen")); return data; } }; @@ -54,6 +90,101 @@ struct jd_domain_comparator_t { namespace detail { +using json_record_key_t = std::tuple; +using previous_record_index_t = std::map; +using previous_record_group_t = std::map>; + +inline std::string lowercase_copy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +inline json_record_key_t make_json_record_key(std::string domain_name, + dns_record_type_e const type, + std::string rdata) { + return {lowercase_copy(std::move(domain_name)), type, + lowercase_copy(std::move(rdata))}; +} + +inline previous_record_index_t make_previous_record_index( + std::optional> const &previous_data) { + previous_record_index_t index{}; + if (!previous_data) { + return index; + } + for (auto const &record : *previous_data) { + index[make_json_record_key(record.domain_name, record.type, record.rdata)] = + record; + } + return index; +} + +inline previous_record_group_t make_previous_record_groups( + std::optional> const &previous_data) { + previous_record_group_t groups{}; + if (!previous_data) { + return groups; + } + for (auto const &record : *previous_data) { + groups[lowercase_copy(record.domain_name)].push_back(record); + } + return groups; +} + +inline json::object_t make_historical_dns_record_json( + json_data_t const &previous_record, std::string const ¤t_datetime) { + json::object_t dns_object; + dns_object["ttl"] = previous_record.ttl; + dns_object["type"] = dns_record_type_to_str(previous_record.type); + dns_object["info"] = previous_record.rdata; + + auto first_seen = previous_record.first_seen; + if (first_seen.empty()) { + first_seen = previous_record.last_seen.empty() ? current_datetime + : previous_record.last_seen; + } + auto last_seen = previous_record.last_seen.empty() ? first_seen + : previous_record.last_seen; + dns_object["first-seen"] = std::move(first_seen); + dns_object["last-seen"] = std::move(last_seen); + dns_object["seen"] = previous_record.seen > 0 ? previous_record.seen : 1; + return dns_object; +} + +template +json::object_t make_dns_record_json( + std::string const &domain_name, DnsType const &dns_record, + previous_record_index_t const &previous_record_index, + std::string const ¤t_datetime) { + json::object_t dns_object; + dns_object["ttl"] = dns_record.ttl; + dns_object["type"] = dns_record_type_to_str(dns_record.type); + dns_object["info"] = dns_record.rdata; + + auto const key = + make_json_record_key(domain_name, dns_record.type, dns_record.rdata); + auto const previous_record_iter = previous_record_index.find(key); + if (previous_record_iter == previous_record_index.cend()) { + dns_object["first-seen"] = current_datetime; + dns_object["last-seen"] = current_datetime; + dns_object["seen"] = 1; + return dns_object; + } + + auto const &previous_record = previous_record_iter->second; + auto first_seen = previous_record.first_seen; + if (first_seen.empty()) { + first_seen = previous_record.last_seen.empty() ? current_datetime + : previous_record.last_seen; + } + dns_object["first-seen"] = std::move(first_seen); + dns_object["last-seen"] = current_datetime; + dns_object["seen"] = previous_record.seen > 0 ? previous_record.seen + 1 : 2; + return dns_object; +} + template void write_json_result_impl(map_container_t const &result_map, RtType const &rt_args) { @@ -67,10 +198,39 @@ void write_json_result_impl(map_container_t const &result_map, } json::array_t list; + auto const previous_record_index = + make_previous_record_index(rt_args.previous_data); + auto const previous_record_groups = + make_previous_record_groups(rt_args.previous_data); + auto const current_datetime = current_us_datetime(); + std::set output_domain_keys; for (auto const &result_pair : result_map.cresult()) { json::object_t internal_object; auto &http_result = result_pair.second.http_result_; - internal_object["dns_probe"] = result_pair.second.dns_result_list_; + json::array_t dns_probe_list; + std::set current_record_keys; + for (auto const &dns_record : result_pair.second.dns_result_list_) { + current_record_keys.insert(make_json_record_key( + result_pair.first, dns_record.type, dns_record.rdata)); + dns_probe_list.push_back(make_dns_record_json( + result_pair.first, dns_record, previous_record_index, current_datetime)); + } + + auto const domain_key = lowercase_copy(result_pair.first); + output_domain_keys.insert(domain_key); + auto const previous_group_iter = previous_record_groups.find(domain_key); + if (previous_group_iter != previous_record_groups.cend()) { + for (auto const &previous_record : previous_group_iter->second) { + auto const record_key = make_json_record_key( + previous_record.domain_name, previous_record.type, previous_record.rdata); + if (current_record_keys.find(record_key) != current_record_keys.cend()) { + continue; + } + dns_probe_list.push_back( + make_historical_dns_record_json(previous_record, current_datetime)); + } + } + internal_object["dns_probe"] = std::move(dns_probe_list); internal_object["content_length"] = http_result.content_length_; internal_object["http_code"] = http_result.http_status_; internal_object["code_string"] = code_string(http_result.http_status_); @@ -79,6 +239,34 @@ void write_json_result_impl(map_container_t const &result_map, object[result_pair.first] = internal_object; list.push_back(std::move(object)); } + + for (auto const &previous_group_pair : previous_record_groups) { + if (output_domain_keys.find(previous_group_pair.first) != + output_domain_keys.cend()) { + continue; + } + auto const &records = previous_group_pair.second; + if (records.empty()) { + continue; + } + + json::array_t dns_probe_list; + for (auto const &previous_record : records) { + dns_probe_list.push_back( + make_historical_dns_record_json(previous_record, current_datetime)); + } + + auto const &first_record = records.front(); + json::object_t internal_object; + internal_object["dns_probe"] = std::move(dns_probe_list); + internal_object["content_length"] = first_record.content_length; + internal_object["http_code"] = first_record.http_code; + internal_object["code_string"] = code_string(first_record.http_code); + + json::object_t object; + object[first_record.domain_name] = std::move(internal_object); + list.push_back(std::move(object)); + } json::object_t res_object; res_object["program"] = "dooked"; diff --git a/dooked/source/cli_preprocessor.cpp b/dooked/source/cli_preprocessor.cpp index c08d7fb..19a7b79 100644 --- a/dooked/source/cli_preprocessor.cpp +++ b/dooked/source/cli_preprocessor.cpp @@ -6,7 +6,10 @@ #include "utils/string_utils.hpp" #include #include +#include +#include #include +#include #include // defined (and assigned to) in main.cpp @@ -18,6 +21,131 @@ namespace dooked { namespace net = boost::asio; using namespace fmt::v7::literals; +std::optional parse_us_datetime(std::string const &datetime) { + if (datetime.empty()) { + return std::nullopt; + } + + for (auto const *format : + {"%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M", "%m/%d/%Y"}) { + std::tm parsed_time{}; + parsed_time.tm_isdst = -1; + std::istringstream input{datetime}; + input >> std::get_time(&parsed_time, format); + if (!input.fail()) { + return std::mktime(&parsed_time); + } + } + return std::nullopt; +} + +detail::previous_record_index_t make_previous_index( + std::optional> const &previous_result) { + return detail::make_previous_record_index(previous_result); +} + +std::set make_current_key_set( + map_container_t const ¤t_result) { + std::set current_keys{}; + for (auto const &domain_result_pair : current_result.cresult()) { + for (auto const &record : domain_result_pair.second.dns_result_list_) { + current_keys.insert(detail::make_json_record_key( + domain_result_pair.first, record.type, record.rdata)); + } + } + return current_keys; +} + +void report_first_seen_records( + map_container_t const ¤t_result, + detail::previous_record_index_t const &previous_index) { + for (auto const &domain_result_pair : current_result.cresult()) { + for (auto const &record : domain_result_pair.second.dns_result_list_) { + auto const key = detail::make_json_record_key( + domain_result_pair.first, record.type, record.rdata); + if (previous_index.find(key) != previous_index.cend()) { + continue; + } + spdlog::info("[FIRST-SEEN][{}][{}] `{}`", domain_result_pair.first, + dns_record_type_to_str(record.type), record.rdata); + } + } +} + +bool should_report_last_seen(json_data_t const &previous_record, + runtime_args_t const &rt_args, + std::optional const date_cutoff) { + if (rt_args.last_seen_days >= 0) { + auto const previous_last_seen = parse_us_datetime(previous_record.last_seen); + if (!previous_last_seen) { + return true; + } + auto const day_seconds = static_cast(24 * 60 * 60); + auto const cutoff = + std::time(nullptr) - + (static_cast(rt_args.last_seen_days) * day_seconds); + return *previous_last_seen <= cutoff; + } + if (!date_cutoff) { + return false; + } + + auto const previous_last_seen = parse_us_datetime(previous_record.last_seen); + return !previous_last_seen || *previous_last_seen <= *date_cutoff; +} + +void report_last_seen_records( + std::vector const &previous_result, + std::set const ¤t_keys, + runtime_args_t const &rt_args) { + std::optional date_cutoff{}; + if (!rt_args.last_seen_date.empty()) { + date_cutoff = parse_us_datetime(rt_args.last_seen_date); + if (!date_cutoff) { + return spdlog::error("Invalid --lsd datetime `{}`", + rt_args.last_seen_date); + } + } + + for (auto const &previous_record : previous_result) { + auto const key = detail::make_json_record_key( + previous_record.domain_name, previous_record.type, previous_record.rdata); + if (current_keys.find(key) != current_keys.cend()) { + continue; + } + if (!should_report_last_seen(previous_record, rt_args, date_cutoff)) { + continue; + } + auto const last_seen = + previous_record.last_seen.empty() ? "unknown" : previous_record.last_seen; + spdlog::warn("[LAST-SEEN][{}][{}] `{}` last seen `{}`", + previous_record.domain_name, + dns_record_type_to_str(previous_record.type), + previous_record.rdata, last_seen); + } +} + +void report_seen_alerts( + std::optional> const &previous_result, + map_container_t const ¤t_result, + runtime_args_t const &rt_args) { + if (!rt_args.show_first_seen && rt_args.last_seen_days < 0 && + rt_args.last_seen_date.empty()) { + return; + } + + auto const previous_index = make_previous_index(previous_result); + if (rt_args.show_first_seen) { + report_first_seen_records(current_result, previous_index); + } + if (!previous_result || + (rt_args.last_seen_days < 0 && rt_args.last_seen_date.empty())) { + return; + } + report_last_seen_records(*previous_result, make_current_key_set(current_result), + rt_args); +} + void compare_http_result(int const base_cl, json_data_t const &prev_http_result, http_response_t const ¤t_result) { auto const current_req_cl = current_result.content_length_; @@ -354,6 +482,7 @@ void start_name_checking(runtime_args_t &&rt_args) { spdlog::info("Writing JSON output"); } write_json_result(result_map, rt_args); + report_seen_alerts(rt_args.previous_data, result_map, rt_args); // compare old with new result -- only if we had previous record if (rt_args.previous_data) { @@ -477,6 +606,9 @@ void run_program(cli_args_t const &cli_args) { static_cast(cli_args.post_http_request); rt_args.thread_count = cli_args.thread_count; rt_args.content_length = cli_args.content_length; + rt_args.last_seen_days = cli_args.last_seen_days; + rt_args.last_seen_date = cli_args.last_seen_date; + rt_args.show_first_seen = cli_args.show_first_seen; return start_name_checking(std::move(rt_args)); } diff --git a/dooked/source/main.cpp b/dooked/source/main.cpp index cf29460..3d6b4c9 100644 --- a/dooked/source/main.cpp +++ b/dooked/source/main.cpp @@ -36,6 +36,14 @@ int main(int argc, char **argv) { "show content lengths that changed more than --content-length"); app.add_flag("-d,--include-date", cli_args.include_date, "append present datetime(-ddMMyyyy_hhmmss) in output name"); + app.add_flag("--fs,--first-seen", cli_args.show_first_seen, + "show records first seen in the current run"); + app.add_option("--ls,--last-seen", cli_args.last_seen_days, + "show missing records last seen at least N days ago") + ->check(CLI::NonNegativeNumber); + app.add_option("--lsd,--last-seen-date", cli_args.last_seen_date, + "show records not seen since a US datetime, for example " + "03/15/2021 or 03/15/2021 14:30:00"); app.add_flag( "--defer", cli_args.post_http_request, "defers http request until after all DNS requests have been completed"); diff --git a/dooked/source/utils/io_utils.cpp b/dooked/source/utils/io_utils.cpp index a1bd5d3..420e2c9 100644 --- a/dooked/source/utils/io_utils.cpp +++ b/dooked/source/utils/io_utils.cpp @@ -51,4 +51,26 @@ std::string get_filepath(std::string const &filename) { return std::filesystem::path(filename).replace_extension().string(); } +std::string current_us_datetime() { + auto const current_time = std::time(nullptr); +#if _MSC_VER && !__INTEL_COMPILER +#pragma warning(disable : 4996) +#endif + auto const local_time = std::localtime(¤t_time); + if (!local_time) { + return {}; + } + + std::string output; + output.resize(32); + auto const trimmed_size = + std::strftime(output.data(), output.size(), "%m/%d/%Y %H:%M:%S", + local_time); + if (trimmed_size == 0) { + return {}; + } + output.resize(trimmed_size); + return output; +} + } // namespace dooked From 2d6b0013c6c1bbcbee5d0c11bd4caab5f7c82e25 Mon Sep 17 00:00:00 2001 From: Leonardo Vera Date: Thu, 25 Jun 2026 16:14:34 -0600 Subject: [PATCH 2/5] Track active state for historical DNS records --- dooked/include/utils/io_utils.hpp | 15 +++++++++++++++ dooked/source/cli_preprocessor.cpp | 19 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/dooked/include/utils/io_utils.hpp b/dooked/include/utils/io_utils.hpp index 0197b49..79dff0e 100644 --- a/dooked/include/utils/io_utils.hpp +++ b/dooked/include/utils/io_utils.hpp @@ -36,6 +36,7 @@ struct json_data_t { std::string first_seen{}; std::string last_seen{}; int seen{}; + bool currently_seen{true}; int ttl{}; int http_code{}; int content_length{}; @@ -68,6 +69,14 @@ struct json_data_t { } return json::number_integer_t{}; }; + auto const read_optional_bool = [&json_object](char const *key, + bool const fallback) { + auto const iter = json_object.find(key); + if (iter != json_object.cend() && iter->second.is_boolean()) { + return iter->second.get(); + } + return fallback; + }; data.first_seen = read_optional_string("first-seen"); if (data.first_seen.empty()) { @@ -78,6 +87,9 @@ struct json_data_t { data.last_seen = read_optional_string("last_seen"); } data.seen = static_cast(read_optional_int("seen")); + data.currently_seen = read_optional_bool("currently-seen", true); + data.currently_seen = read_optional_bool("currently_seen", + data.currently_seen); return data; } }; @@ -150,6 +162,7 @@ inline json::object_t make_historical_dns_record_json( dns_object["first-seen"] = std::move(first_seen); dns_object["last-seen"] = std::move(last_seen); dns_object["seen"] = previous_record.seen > 0 ? previous_record.seen : 1; + dns_object["currently-seen"] = false; return dns_object; } @@ -170,6 +183,7 @@ json::object_t make_dns_record_json( dns_object["first-seen"] = current_datetime; dns_object["last-seen"] = current_datetime; dns_object["seen"] = 1; + dns_object["currently-seen"] = true; return dns_object; } @@ -182,6 +196,7 @@ json::object_t make_dns_record_json( dns_object["first-seen"] = std::move(first_seen); dns_object["last-seen"] = current_datetime; dns_object["seen"] = previous_record.seen > 0 ? previous_record.seen + 1 : 2; + dns_object["currently-seen"] = true; return dns_object; } diff --git a/dooked/source/cli_preprocessor.cpp b/dooked/source/cli_preprocessor.cpp index 19a7b79..2f4b19a 100644 --- a/dooked/source/cli_preprocessor.cpp +++ b/dooked/source/cli_preprocessor.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -146,6 +147,16 @@ void report_seen_alerts( rt_args); } +std::vector +active_previous_records(std::vector const &previous_result) { + std::vector active_records{}; + active_records.reserve(previous_result.size()); + std::copy_if(previous_result.cbegin(), previous_result.cend(), + std::back_inserter(active_records), + [](json_data_t const &record) { return record.currently_seen; }); + return active_records; +} + void compare_http_result(int const base_cl, json_data_t const &prev_http_result, http_response_t const ¤t_result) { auto const current_req_cl = current_result.content_length_; @@ -487,6 +498,7 @@ void start_name_checking(runtime_args_t &&rt_args) { // compare old with new result -- only if we had previous record if (rt_args.previous_data) { auto &previous_data = *rt_args.previous_data; + auto active_previous_data = active_previous_records(previous_data); // sort the (domain)names in (alphabetical, record type) tuple order std::sort(previous_data.begin(), previous_data.end(), @@ -494,6 +506,11 @@ void start_name_checking(runtime_args_t &&rt_args) { return std::tie(a.domain_name, a.type) < std::tie(b.domain_name, b.type); }); + std::sort(active_previous_data.begin(), active_previous_data.end(), + [](json_data_t const &a, json_data_t const &b) { + return std::tie(a.domain_name, a.type) < + std::tie(b.domain_name, b.type); + }); auto &result = result_map.result(); for (auto &res : result) { std::sort(res.second.dns_result_list_.begin(), @@ -502,7 +519,7 @@ void start_name_checking(runtime_args_t &&rt_args) { return std::tie(a.type, a.rdata) < std::tie(b.type, b.rdata); }); } - return compare_results(*rt_args.previous_data, result_map, + return compare_results(active_previous_data, result_map, rt_args.content_length); } } From afb1e8a4bcfd4aeae52c7b5e5a7213c0af54e2a3 Mon Sep 17 00:00:00 2001 From: Leonardo Vera Date: Thu, 25 Jun 2026 16:28:16 -0600 Subject: [PATCH 3/5] Document DNS history metadata flags --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index f1a761c..fa0ddba 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,31 @@ make ## Usage For comprehensive help, use `dooked --help` + +### DNS history metadata + +When a previous JSON result is used as input, dooked preserves DNS record +history in each `dns_probe` item: + +- `first-seen`: when this DNS record was first observed. +- `last-seen`: when this DNS record was most recently observed. +- `seen`: how many runs have observed this DNS record. +- `currently-seen`: whether this DNS record was present in the latest run. + +Historical records that are missing from the latest DNS response are kept in +the JSON output with `currently-seen: false`. This keeps load-balanced or +rotating records searchable without making the default comparison repeatedly +report old historical records as newly missing. + +Useful flags: + +``` +dooked -i previous.json --fs +dooked -i previous.json --ls 2 +dooked -i previous.json --lsd "03/15/2021" +``` + +- `--fs` / `--first-seen`: print records first observed in the current run. +- `--ls` / `--last-seen N`: print missing records last seen at least `N` days ago. +- `--lsd` / `--last-seen-date`: print missing records last seen before a US + date or datetime, for example `03/15/2021` or `03/15/2021 14:30:00`. From b9dda64608cf6a016cf4de4ab3765efa7763358e Mon Sep 17 00:00:00 2001 From: Leonardo Vera Date: Thu, 25 Jun 2026 16:51:04 -0600 Subject: [PATCH 4/5] Add DNS history regression test --- dooked/CMakeLists.txt | 31 ++++-- dooked/include/utils/io_utils.hpp | 8 +- dooked/tests/dns_history_test.cpp | 169 ++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 dooked/tests/dns_history_test.cpp diff --git a/dooked/CMakeLists.txt b/dooked/CMakeLists.txt index c43ff38..c9ba928 100644 --- a/dooked/CMakeLists.txt +++ b/dooked/CMakeLists.txt @@ -43,13 +43,13 @@ endif(NOT CMAKE_BUILD_TYPE) ############################################################ if(CMAKE_BUILD_TYPE STREQUAL "Debug") - set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${PROJECT_DIR}/${OUTPUT_DEBUG}") - set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_DIR}/${OUTPUT_DEBUG}") - set(CMAKE_EXECUTABLE_OUTPUT_DIRECTORY "${PROJECT_DIR}/${OUTPUT_DEBUG}") + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_DEBUG}") + set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${OUTPUT_DEBUG}") + set(CMAKE_EXECUTABLE_OUTPUT_DIRECTORY "${OUTPUT_DEBUG}") else() - set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${PROJECT_DIR}/${OUTPUT_RELEASE}") - set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_DIR}/${OUTPUT_RELEASE}") - set(CMAKE_EXECUTABLE_OUTPUT_DIRECTORY "${PROJECT_DIR}/${OUTPUT_RELEASE}") + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${OUTPUT_RELEASE}") + set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${OUTPUT_RELEASE}") + set(CMAKE_EXECUTABLE_OUTPUT_DIRECTORY "${OUTPUT_RELEASE}") endif() # Messages @@ -103,6 +103,25 @@ add_executable(${PROJECT_NAME} ${SRC_FILES} ${HEADERS_FILES} ) +option(DOOKED_BUILD_HISTORY_TESTS "Build DNS history metadata regression tests" OFF) + +if(DOOKED_BUILD_HISTORY_TESTS) + enable_testing() + add_executable(dooked_dns_history_test + ./tests/dns_history_test.cpp + ./source/utils/constants.cpp + ./source/utils/io_utils.cpp + ./source/utils/string_utils.cpp + ) + set_target_properties(dooked_dns_history_test PROPERTIES LINK_LIBRARIES "") + target_compile_features(dooked_dns_history_test PRIVATE cxx_std_17) + target_include_directories(dooked_dns_history_test PRIVATE + ${PROJECT_DIR}/include + ${PROJECT_DIR}/json/single_include + ) + add_test(NAME dooked_dns_history_test COMMAND dooked_dns_history_test) +endif() + if(NOT MSVC) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17 -O3") if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang") diff --git a/dooked/include/utils/io_utils.hpp b/dooked/include/utils/io_utils.hpp index 79dff0e..db7927f 100644 --- a/dooked/include/utils/io_utils.hpp +++ b/dooked/include/utils/io_utils.hpp @@ -309,10 +309,10 @@ std::optional> read_json_string(Iterator const begin, auto internal_object = json_item.second.get(); auto const domain_detail_list = internal_object["dns_probe"].get(); - auto const content_length = - internal_object["content_length"].get(); - auto const http_code = - internal_object["http_code"].get(); + auto const content_length = static_cast( + internal_object["content_length"].get()); + auto const http_code = static_cast( + internal_object["http_code"].get()); for (auto const &domain_detail : domain_detail_list) { auto domain_object = domain_detail.get(); diff --git a/dooked/tests/dns_history_test.cpp b/dooked/tests/dns_history_test.cpp new file mode 100644 index 0000000..15d76d5 --- /dev/null +++ b/dooked/tests/dns_history_test.cpp @@ -0,0 +1,169 @@ +#include "utils/io_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void check(bool condition, char const *message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +struct test_runtime_args_t { + std::optional> previous_data{}; + std::unique_ptr output_file{}; + std::string output_filename{}; +}; + +dooked::json::array_t dns_probe_for(dooked::json const &output, + std::string const &domain) { + auto const &results = output.at("result").get_ref(); + for (auto const &entry : results) { + auto const object = entry.get(); + auto const found = object.find(domain); + if (found != object.end()) { + return found->second.at("dns_probe").get(); + } + } + throw std::runtime_error("domain not found: " + domain); +} + +dooked::json::object_t record_for(dooked::json::array_t const &records, + std::string const &rdata) { + for (auto const &record : records) { + auto const object = record.get(); + if (object.at("info").get() == rdata) { + return object; + } + } + throw std::runtime_error("record not found: " + rdata); +} + +} // namespace + +int main() try { + std::string const previous_json = R"json( +{ + "program": "dooked", + "result": [ + { + "Example.COM": { + "content_length": 123, + "http_code": 200, + "dns_probe": [ + { + "ttl": 60, + "type": "A", + "info": "1.1.1.1", + "first_seen": "01/02/2024 00:00:00", + "last_seen": "01/03/2024 00:00:00", + "seen": 3, + "currently_seen": true + }, + { + "ttl": 60, + "type": "A", + "info": "2.2.2.2", + "first-seen": "01/02/2024 00:00:00", + "last-seen": "01/03/2024 00:00:00", + "seen": 2, + "currently-seen": true + } + ] + } + }, + { + "old.example": { + "content_length": 77, + "http_code": 200, + "dns_probe": [ + { + "ttl": 300, + "type": "A", + "info": "3.3.3.3", + "first-seen": "01/01/2024 00:00:00", + "last-seen": "01/02/2024 00:00:00", + "seen": 1, + "currently-seen": true + } + ] + } + } + ] +} +)json"; + + auto previous_data = + dooked::detail::read_json_string( + previous_json.cbegin(), previous_json.cend()); + check(previous_data.has_value(), "previous data should parse"); + check(previous_data->size() == 3, "previous data should include 3 records"); + check((*previous_data)[0].first_seen == "01/02/2024 00:00:00", + "snake_case first_seen should be read"); + check((*previous_data)[0].last_seen == "01/03/2024 00:00:00", + "snake_case last_seen should be read"); + check((*previous_data)[0].currently_seen, + "snake_case currently_seen should be read"); + + dooked::map_container_t result_map{}; + result_map.insert("example.com", 456, 200); + result_map.append("example.com", + {"1.1.1.1", dooked::dns_record_type_e::DNS_REC_A, 120}); + + auto const output_path = + std::filesystem::temp_directory_path() / "dooked_dns_history_test.json"; + test_runtime_args_t rt_args{}; + rt_args.previous_data = std::move(previous_data); + rt_args.output_filename = output_path.string(); + rt_args.output_file = std::make_unique(output_path); + + dooked::write_json_result(result_map, rt_args); + + std::ifstream output_file{output_path}; + dooked::json output = dooked::json::parse(output_file); + auto const &example_records = dns_probe_for(output, "example.com"); + check(example_records.size() == 2, + "example.com should include current and historical records"); + + auto const ¤t_record = record_for(example_records, "1.1.1.1"); + check(current_record.at("first-seen") == "01/02/2024 00:00:00", + "current record should preserve first-seen"); + check(current_record.at("seen") == 4, + "current record should increment seen count"); + check(current_record.at("currently-seen") == true, + "current record should be marked currently-seen"); + + auto const &historical_record = record_for(example_records, "2.2.2.2"); + check(historical_record.at("first-seen") == "01/02/2024 00:00:00", + "historical record should preserve first-seen"); + check(historical_record.at("last-seen") == "01/03/2024 00:00:00", + "historical record should preserve last-seen"); + check(historical_record.at("seen") == 2, + "historical record should preserve seen count"); + check(historical_record.at("currently-seen") == false, + "historical record should be marked not currently-seen"); + + auto const &old_records = dns_probe_for(output, "old.example"); + check(old_records.size() == 1, + "missing domain should be preserved in output history"); + auto const &old_record = record_for(old_records, "3.3.3.3"); + check(old_record.at("currently-seen") == false, + "missing domain record should be marked not currently-seen"); + check(old_record.at("last-seen") == "01/02/2024 00:00:00", + "missing domain record should preserve last-seen"); + + output_file.close(); + std::filesystem::remove(output_path); + std::cout << "dns history metadata test passed\n"; + return 0; +} catch (std::exception const &error) { + std::cerr << "dns history metadata test failed: " << error.what() << "\n"; + return 1; +} From 7e260bad2df115c3f272f0401289e44dd621ab4d Mon Sep 17 00:00:00 2001 From: Leonardo Vera Date: Thu, 25 Jun 2026 17:13:24 -0600 Subject: [PATCH 5/5] Add DNS history regression workflow --- .github/workflows/dns-history-test.yml | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/dns-history-test.yml diff --git a/.github/workflows/dns-history-test.yml b/.github/workflows/dns-history-test.yml new file mode 100644 index 0000000..f7c565b --- /dev/null +++ b/.github/workflows/dns-history-test.yml @@ -0,0 +1,31 @@ +name: DNS history regression test + +on: + pull_request: + paths: + - "dooked/**" + - ".github/workflows/dns-history-test.yml" + push: + branches: + - master + - main + +jobs: + dns-history-test: + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Configure isolated history test + run: > + cmake -S dooked -B build + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + -DDOOKED_BUILD_HISTORY_TESTS=ON + + - name: Build isolated history test + run: cmake --build build --target dooked_dns_history_test --config Release + + - name: Run isolated history test + run: ctest --test-dir build --output-on-failure