From fac9a9e0d53109e621ed0aa6ad520d9c4c390806 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Wed, 4 Nov 2026 21:38:42 +0000 Subject: [PATCH 1/5] docs: allow all of the Conventional Commits prefixes Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1404155..adb4203 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ any I/O, and [prober.cpp](src/prober.cpp) does the rest. The tests in [test/](te ## Style As in anyhttp: clang-format with 3-space indent and 100 columns, members `foo_`, functions -`snake_case`, types `CamelCase`. Commit subjects are prefixed `fix:`, `refactor:`, `docs:`, `chore:` -or `test:`. +`snake_case`, types `CamelCase`. Commit subjects are prefixed `feat:`, `fix:`, `docs:`, `style:`, +`refactor:`, `perf:`, `test:`, `build:`, `ci:`, `chore:` or `revert:`. Commit or push only when asked. Push over SSH to git@github.com; Peter opens the pull requests. From 9857c112da7e2e693038372efdb75ef7168fe6ec Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Wed, 4 Nov 2026 21:36:30 +0000 Subject: [PATCH 2/5] feat: add --dns-only, to stop after the lookups and connect to nothing A host that would have been connected to now goes by the outcome "resolved", with its records and all of its addresses in the order they would be tried. All other outcomes stay as they are, and --h3 still applies. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++ src/main.cpp | 3 ++ src/output.cpp | 12 ++++- src/prober.cpp | 8 +++ src/prober.hpp | 9 +++- test/CMakeLists.txt | 5 ++ test/output_test.cpp | 26 ++++++++++ test/prober_test.cpp | 117 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 193 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1a7918a..e8b1e04 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ look like this, on stderr: | does not exist | not found, not connected to | | gets no answer from DNS, or has no address | lookup failed, so there is no telling, not connected to | | has no record that offers `h3`, with `--h3` | without h3, not connected to | +| could be connected to, with `--dns-only` | resolved, not connected to | Aliases are followed, a record has to be compatible, and the lowest priority goes first. Within a record, HTTP/3 goes first, and then TCP on the same port: HTTP/2 if the record has `h2`, otherwise @@ -49,6 +50,21 @@ target, and they are treated as above, so that one can still end up out of reach when HTTP/3 can not be connected to. The others are left out after their HTTPS lookup, and printed only with `--all`. The counts at the end then add how many there were, as `5 without h3`. +**With `--dns-only`,** it stops at the point where it would connect, so that no host is connected to +or requested. A host that would have been goes by `resolved` and comes with its records and all of +its addresses, in the order they would be tried: + +``` +example.com: resolved + record: HTTPS 1 . alpn=h3,h2 + address: 2001:db8::1 + address: 192.0.2.1 +``` + +All other hosts get the outcome they always get, which leaves `answered` and `failed` at 0. The addresses +are only looked up for a host that would have been connected to, so an `out of reach` one has none. +The counts at the end then add how many there were, as `5 resolved`, and `--h3` works as above. + **One request means one.** The next route is for when connecting failed. Once a request has gone out, the host has been asked, so that a timeout or a response cut short ends it there, as a failure. diff --git a/src/main.cpp b/src/main.cpp index a803f7f..633c18f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -160,6 +160,9 @@ po::options_description describe_options(Arguments& arguments) "how long one request may take"); opts("h3", po::bool_switch(&config.h3_only), "connect only to the hosts with an HTTPS record that offers HTTP/3"); + opts("dns-only", po::bool_switch(&config.dns_only), + "connect to nothing: stop after the lookups, and print the records and the addresses of " + "each host"); opts("all", po::bool_switch(&arguments.all), "also print the hosts that do not exist, and those that --h3 leaves out"); opts("color", po::bool_switch(&arguments.color), diff --git a/src/output.cpp b/src/output.cpp index d332696..4238e97 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -117,10 +117,15 @@ std::string format_result(const Result& result, bool color) case Outcome::no_h3: text = std::format("{}: no h3 record\n", host); break; + case Outcome::resolved: + text = std::format("{}: {}\n", host, colored("resolved", color_of::good, color)); + break; } for (const auto& record : result.records) text += std::format(" record: {}\n", record_text(record, color)); + for (const auto& address : result.addresses) + text += std::format(" address: {}\n", address_text(address, color)); for (const auto& note : result.notes) text += colored(std::format(" note: {}", note), color_of::note, color) + "\n"; for (const auto& [name, value] : result.fields) @@ -137,11 +142,14 @@ std::string format_summary(Counts counts, bool color) return counts[outcome] ? colored(line, code, color) : line; }; return std::format( - "{}, {}, {}, {} not found, {}{}", count(Outcome::answered, "answered", color_of::good), + "{}, {}, {}, {} not found, {}{}{}", count(Outcome::answered, "answered", color_of::good), count(Outcome::failed, "failed", color_of::bad), count(Outcome::out_of_reach, "out of reach", color_of::moved), counts[Outcome::not_found], count(Outcome::lookup_failed, "lookups failed", color_of::bad), - counts[Outcome::no_h3] ? std::format(", {} without h3", counts[Outcome::no_h3]) : ""); + counts[Outcome::no_h3] ? std::format(", {} without h3", counts[Outcome::no_h3]) : "", + counts[Outcome::resolved] + ? std::format(", {}", count(Outcome::resolved, "resolved", color_of::good)) + : ""); } // ================================================================================================= diff --git a/src/prober.cpp b/src/prober.cpp index c151403..e28c31c 100644 --- a/src/prober.cpp +++ b/src/prober.cpp @@ -393,6 +393,14 @@ capy::task Prober::probe(const std::string& host) // prefer_ipv6(addresses); + if (config_.dns_only) + { + result.outcome = Result::Outcome::resolved; + for (const auto& address : addresses) + result.addresses.push_back(address.address().to_string()); + co_return result; + } + result.outcome = Result::Outcome::failed; for (const auto& route : plan.routes) { diff --git a/src/prober.hpp b/src/prober.hpp index 9247c02..ca6ede1 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -70,6 +70,10 @@ struct Config /// treated as usual, which includes going over TCP if HTTP/3 can not be connected to. bool h3_only = false; + /// Stop after the lookups, with the addresses that it would have gone on to connect to, and + /// connect to nothing. See Result::Outcome::resolved. + bool dns_only = false; + /// Where the messages of this program and of c-ares go. anyhttp can't be told: it always uses /// the default logger of spdlog, so a logger other than that one can have a level of its own. std::shared_ptr logger = spdlog::default_logger(); @@ -100,6 +104,7 @@ struct Result not_found, ///< not connected to, the name does not exist lookup_failed, ///< not connected to, DNS gave no answer, so there is no telling no_h3, ///< not connected to, it has no record that offers HTTP/3, see Config::h3_only + resolved, ///< not connected to, but it could have been tried, see Config::dns_only }; std::string host; @@ -115,6 +120,7 @@ struct Result /// The address that answered or, with \c failed, the last one contacted. Empty without one. std::string address; + std::vector addresses; ///< with \c resolved: all of them, in the order of trying std::string error; ///< with \c failed: what went wrong on the last route std::vector notes; ///< records that could not be used, routes that did not work }; @@ -125,7 +131,8 @@ struct Result * trying its addresses one at a time on each route, IPv6 first. A response is what comes back, * whatever its status: a redirect is reported and not followed, nothing in the body is looked at, * and nothing else on the host is requested. The next route is for a connection that did not come - * about: once a request has gone out, that was it for this host, however it ended. + * about: once a request has gone out, that was it for this host, however it ended. With + * Config::dns_only, it is the lookups and nothing else. * * Hosts are worked on Config::concurrency at a time, so results arrive as they are ready. All of * this is on the one executor, which is also what calls the observer. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1bd0cee..837bbf8 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -89,6 +89,11 @@ add_test(NAME http_probe_refuses_tls_options_without_dot set_tests_properties(http_probe_refuses_tls_options_without_dot PROPERTIES PASS_REGULAR_EXPRESSION "only apply with --dot") +add_test(NAME http_probe_takes_dns_only + COMMAND http-probe --dns-only --dns-server 127.0.0.1:1 localhost) +set_tests_properties(http_probe_takes_dns_only PROPERTIES + PASS_REGULAR_EXPRESSION "1 lookups failed") + # # The levels of logging, as far as they show without a connection: the lookups fail on a port of # loopback that nothing listens on, so this needs nothing but the program. diff --git a/test/output_test.cpp b/test/output_test.cpp index b8a4a35..ea5791d 100644 --- a/test/output_test.cpp +++ b/test/output_test.cpp @@ -88,6 +88,32 @@ TEST(Output, CountsTheHostsLeftOutOnlyIfThereAreAny) EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("h3"))); } +TEST(Output, ListsTheAddressesOfAHostThatWasOnlyLookedUp) +{ + const Result result{.host = "example.com", + .outcome = Outcome::resolved, + .records = {{.priority = 1, .target = ".", .params = {"alpn=h3"}}}, + .addresses = {"2001:db8::1", "192.0.2.1"}}; + + EXPECT_EQ(format_result(result, false), "example.com: resolved\n" + " record: HTTPS 1 . alpn=h3\n" + " address: 2001:db8::1\n" + " address: 192.0.2.1\n"); + + const auto text = format_result(result, true); + EXPECT_THAT(text, HasSubstr("\x1b[32mresolved\x1b[0m")); + EXPECT_THAT(text, HasSubstr("address: \x1b[34m2001:db8::1\x1b[0m")); + EXPECT_THAT(text, HasSubstr("address: \x1b[35m192.0.2.1\x1b[0m")); +} + +TEST(Output, CountsTheHostsOnlyLookedUpOnlyIfThereAreAny) +{ + EXPECT_EQ(format_summary({{Outcome::resolved, 3}, {Outcome::no_h3, 5}}, false), + "0 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 without h3, " + "3 resolved"); + EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("resolved"))); +} + TEST(Output, ColoursTheWayCAresAsyncDoes) { const auto text = format_result(answered(), true); diff --git a/test/prober_test.cpp b/test/prober_test.cpp index 778d78c..51a5085 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -1048,6 +1048,123 @@ TEST_F(Probe, H3OnlyDoesNotTakeAFailedLookupForNoHttp3) EXPECT_THAT(site.requests(), IsEmpty()); } +// ================================================================================================= +// --dns-only +// ================================================================================================= + +TEST_F(Probe, DnsOnlyReportsTheRecordsAndTheAddressesInTheOrderOfTrying) +{ + site.dns().add_https("site.example.test", 1, ".", h2_at(site.port())); + site.dns().add_address("site.example.test", "127.0.0.1"); + site.dns().add_address("site.example.test", "::1"); + + auto configuration = config({"site.example.test"}); + configuration.dns_only = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::resolved) << notes(results[0]); + EXPECT_EQ(results[0].records.size(), 1u); + EXPECT_THAT(results[0].addresses, ElementsAre("::1", "127.0.0.1")); + EXPECT_THAT(results[0].address, IsEmpty()) << "that is for what was contacted"; +} + +TEST_F(Probe, DnsOnlyDoesNotRequestAHostThatWouldAnswer) +{ + site.dns().add_https("localhost", 1, ".", h3_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.dns_only = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::resolved) << notes(results[0]); + EXPECT_THAT(results[0].addresses, Not(IsEmpty())); + EXPECT_THAT(site.requests(), IsEmpty()); +} + +TEST_F(Probe, DnsOnlyDoesNotRequestAHostWithoutARecord) +{ + site.dns().add_address("localhost", "127.0.0.1"); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.dns_only = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::resolved) << notes(results[0]); + EXPECT_THAT(results[0].records, IsEmpty()); + EXPECT_THAT(site.requests(), IsEmpty()); +} + +TEST_F(Probe, DnsOnlyStillSaysThatARecordLeadsElsewhere) +{ + site.dns().add_https("localhost", 1, "elsewhere.example.test", h3_at(site.port())); + + auto configuration = config(); + configuration.dns_only = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::out_of_reach); +} + +TEST_F(Probe, DnsOnlyStillSaysThatANameDoesNotExist) +{ + auto configuration = config({"nowhere.example.test"}); + configuration.dns_only = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::not_found); +} + +TEST_F(Probe, DnsOnlyStillSaysThatALookupFailed) +{ + site.dns().set_rcode(ARES_RCODE_SERVFAIL); + + auto configuration = config(); + configuration.dns_only = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::lookup_failed); +} + +TEST_F(Probe, DnsOnlyStillSaysThatThereIsNoAddress) +{ + site.dns().add_https("site.example.test", 1, ".", h2_at(site.port())); + + auto configuration = config({"site.example.test"}); + configuration.dns_only = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::lookup_failed); + EXPECT_THAT(notes(results[0]), HasSubstr("address lookup")); +} + +TEST_F(Probe, DnsOnlyGoesWithH3Only) +{ + site.dns().add_https("offers.example.test", 1, ".", h3_at(site.port())); + site.dns().add_address("offers.example.test", "127.0.0.1"); + site.dns().add_https("lacks.example.test", 1, ".", h2_at(site.port())); + site.dns().add_address("lacks.example.test", "127.0.0.1"); + + auto configuration = config({"offers.example.test", "lacks.example.test"}); + configuration.dns_only = true; + configuration.h3_only = true; + configuration.concurrency = 1; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 2u); + EXPECT_EQ(results[0].outcome, Result::Outcome::resolved) << notes(results[0]); + EXPECT_EQ(results[1].outcome, Result::Outcome::no_h3); + EXPECT_THAT(site.requests(), IsEmpty()); +} + // ================================================================================================= // --dot // ================================================================================================= From 625366dce68d0998e22b4f9ff3a602e7a2a880d9 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Wed, 4 Nov 2026 21:57:31 +0000 Subject: [PATCH 3/5] feat: add --each-address, to send a request to each address of a host Every address of a host gets a request of its own, one after the other, and a line with the status line of the response or what went wrong. A host counts as answered only if all of its addresses did, and otherwise as failed. Nothing is followed, and once a request has gone out, that was it for the address. It can't go with --dns-only. This is an exception to one request per host, so the safety rule in CLAUDE.md now allows for it, if explicitly enabled. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 9 ++- README.md | 18 +++++ src/main.cpp | 3 + src/output.cpp | 36 +++++++-- src/prober.cpp | 49 +++++++++++++ src/prober.hpp | 24 +++++- test/CMakeLists.txt | 11 +++ test/output_test.cpp | 30 ++++++++ test/prober_test.cpp | 171 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 337 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adb4203..ddd0400 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,10 +9,11 @@ its own: anyhttp for HTTP and c-ares-async for DNS. These hold for all code in this repository, including tests and experiments. -- **One request per host, and nothing more.** c-ares-async's lists of hosts are fine as input here, - which is what makes this rule matter: they have 1000 mixed sites, some adult or spam-looking. So - nothing is followed: no links, no redirects and no Alt-Svc. Another route is for a connection that - failed: once a request has gone out, that was it for the host, however it ended. +- **One request per hostname, but if explicitly enabled, also one request per IP, and nothing + more.** c-ares-async's lists of hosts are fine as input here, which is what makes this rule + matter: they have 1000 mixed sites, some adult or spam-looking. So nothing is followed: no links, + no redirects and no Alt-Svc. Another route is for a connection that failed: once a request has + gone out, that was it for the hostname, or for the IP if enabled, however it ended. - **Tests connect to local servers on loopback only.** - **If the program ever crawls, it refuses to run without an explicit host allowlist,** with robots.txt and per-host rate limits built in from the start, not added later. diff --git a/README.md b/README.md index e8b1e04..849b30d 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ look like this, on stderr: | gets no answer from DNS, or has no address | lookup failed, so there is no telling, not connected to | | has no record that offers `h3`, with `--h3` | without h3, not connected to | | could be connected to, with `--dns-only` | resolved, not connected to | +| could be connected to, with `--each-address` | answered if all of its addresses did, otherwise failed | Aliases are followed, a record has to be compatible, and the lowest priority goes first. Within a record, HTTP/3 goes first, and then TCP on the same port: HTTP/2 if the record has `h2`, otherwise @@ -65,6 +66,23 @@ All other hosts get the outcome they always get, which leaves `answered` and `fa are only looked up for a host that would have been connected to, so an `out of reach` one has none. The counts at the end then add how many there were, as `5 resolved`, and `--h3` works as above. +**With `--each-address`,** every address of a host gets a request of its own instead of only the first +that can be connected to, one after the other, with a line per address for the status line of the +response or what went wrong: + +``` +example.com: answered + record: HTTPS 1 . alpn=h3,h2 + address: 2001:db8::1 200 OK + address: 192.0.2.1 200 OK +``` + +All other hosts get the outcome they always get. A host counts as `answered` only if all of its +addresses did, whatever the status, and otherwise as `failed`. This is the one exception to one request +per host, and it can't go with `--dns-only`. `--timeout-ms` then is per address on each route. Beyond +that, the next paragraph holds for each address, and nothing is followed here either. HTTP/2 and HTTP/3 +have no reason phrase, so there the `OK` is what that status is called. + **One request means one.** The next route is for when connecting failed. Once a request has gone out, the host has been asked, so that a timeout or a response cut short ends it there, as a failure. diff --git a/src/main.cpp b/src/main.cpp index 633c18f..6eddd78 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -163,6 +163,9 @@ po::options_description describe_options(Arguments& arguments) opts("dns-only", po::bool_switch(&config.dns_only), "connect to nothing: stop after the lookups, and print the records and the addresses of " "each host"); + opts("each-address", po::bool_switch(&config.each_address), + "send a request to each address of a host and print the status line of each, instead of " + "one request per host"); opts("all", po::bool_switch(&arguments.all), "also print the hosts that do not exist, and those that --h3 leaves out"); opts("color", po::bool_switch(&arguments.color), diff --git a/src/output.cpp b/src/output.cpp index 4238e97..6cb60ce 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -43,6 +43,19 @@ std::string address_text(std::string_view address, bool color) return colored(address, v6 ? color_of::v6 : color_of::v4, color); } +// +// What came of the request to one address: the status line, as far as it goes without the version, +// which the routes say more about, or what went wrong. +// +std::string reply_text(const AddressReply& reply, bool color) +{ + if (!reply.error.empty()) + return std::format("{}: {}", colored("failed", color_of::bad, color), reply.error); + return reply.reason.empty() + ? status_text(reply.status, color) + : std::format("{} {}", status_text(reply.status, color), reply.reason); +} + // // The value of an address hint: the addresses have a colour, the commas between them do not. // @@ -95,15 +108,21 @@ std::string format_result(const Result& result, bool color) switch (result.outcome) { case Outcome::answered: - text = std::format("{}: {} [{}] at {}, {} bytes in {}\n", host, - status_text(result.status, color), result.route, - address_text(result.address, color), result.body_size, result.duration); + text = + !result.replies.empty() + ? std::format("{}: {}\n", host, colored("answered", color_of::good, color)) + : std::format("{}: {} [{}] at {}, {} bytes in {}\n", host, + status_text(result.status, color), result.route, + address_text(result.address, color), result.body_size, result.duration); break; case Outcome::failed: - text = std::format( - "{}: {}{}: {}\n", host, colored("failed", color_of::bad, color), - result.address.empty() ? "" : std::format(" at {}", address_text(result.address, color)), - result.error); + text = !result.replies.empty() + ? std::format("{}: {}\n", host, colored("failed", color_of::bad, color)) + : std::format("{}: {}{}: {}\n", host, colored("failed", color_of::bad, color), + result.address.empty() + ? "" + : std::format(" at {}", address_text(result.address, color)), + result.error); break; case Outcome::out_of_reach: text = std::format("{}: {}\n", host, colored("out of reach", color_of::moved, color)); @@ -126,6 +145,9 @@ std::string format_result(const Result& result, bool color) text += std::format(" record: {}\n", record_text(record, color)); for (const auto& address : result.addresses) text += std::format(" address: {}\n", address_text(address, color)); + for (const auto& reply : result.replies) + text += std::format(" address: {} {}\n", address_text(reply.address, color), + reply_text(reply, color)); for (const auto& note : result.notes) text += colored(std::format(" note: {}", note), color_of::note, color) + "\n"; for (const auto& [name, value] : result.fields) diff --git a/src/prober.cpp b/src/prober.cpp index e28c31c..76074cb 100644 --- a/src/prober.cpp +++ b/src/prober.cpp @@ -188,6 +188,8 @@ Prober::Prober(capy::any_executor executor, Config config, Observer observer) throw std::invalid_argument("nothing to do: no host names given"); if (config_.concurrency == 0) throw std::invalid_argument("a concurrency of 0 would never get anywhere"); + if (config_.dns_only && config_.each_address) + throw std::invalid_argument("--dns-only connects to nothing, which rules out --each-address"); std::set seen; for (const auto& host : config_.hosts) @@ -401,6 +403,19 @@ capy::task Prober::probe(const std::string& host) co_return result; } + if (config_.each_address) + { + result.outcome = Result::Outcome::answered; + for (const auto& address : addresses) + { + auto reply = co_await ask_address(plan.routes, host, address, result.notes); + if (!reply.error.empty()) + result.outcome = Result::Outcome::failed; + result.replies.push_back(std::move(reply)); + } + co_return result; + } + result.outcome = Result::Outcome::failed; for (const auto& route : plan.routes) { @@ -440,6 +455,40 @@ capy::task Prober::probe(const std::string& host) co_return result; } +// +// One request to one address: the next route is for a connection that did not come about, and once +// a request has gone out, that was it for this address, however it ended. +// +capy::task Prober::ask_address(const std::vector& routes, + const std::string& host, + const corosio::endpoint& address, + std::vector& notes) +{ + AddressReply reply{.address = address.address().to_string()}; + for (const auto& route : routes) + { + config_.logger->debug("{}: connecting to {} by {}, for {}", host, reply.address, + route.to_string(), route.source); + const auto deadline = std::chrono::steady_clock::now() + config_.timeout; + auto [ec, response] = + co_await attempt(route, host, corosio::endpoint(address, route.port), deadline); + if (!ec) + { + reply.status = static_cast(response.message.result_int()); + reply.reason = response.message.reason(); + reply.error.clear(); + co_return reply; + } + + reply.error = std::format("[{}] {}", route.to_string(), response.error); + notes.push_back(std::format("{} at {}", reply.error, reply.address)); + config_.logger->debug("{}: {} at {}", host, reply.error, reply.address); + if (response.request_sent) + co_return reply; + } + co_return reply; +} + // // Connects as the route says, to this one address so that there is no doubt about which it was, and // asks for the host's root, by the deadline. After that it ends the connection in an orderly way, diff --git a/src/prober.hpp b/src/prober.hpp index ca6ede1..bec1950 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -74,6 +74,10 @@ struct Config /// connect to nothing. See Result::Outcome::resolved. bool dns_only = false; + /// Send a request to each address of a host, one at a time, rather than to the first that can be + /// connected to. Config::timeout then is per address on each route. See Result::replies. + bool each_address = false; + /// Where the messages of this program and of c-ares go. anyhttp can't be told: it always uses /// the default logger of spdlog, so a logger other than that one can have a level of its own. std::shared_ptr logger = spdlog::default_logger(); @@ -93,13 +97,22 @@ std::vector read_hosts(std::istream& list, std::vector /// Moves the IPv6 addresses to the front, each family keeping the order it was in. void prefer_ipv6(std::vector& addresses); +/// What came of the request to one address of a host, see Config::each_address. +struct AddressReply +{ + std::string address; + int status = 0; ///< without \c error + std::string reason; ///< without \c error: "OK", which HTTP/2 and HTTP/3 only know by the status + std::string error; ///< what went wrong on the last route, if no response could be had +}; + /// What came of one host. struct Result { enum class Outcome { - answered, ///< a response arrived, whatever its status - failed, ///< it was connected to, and no response could be had + answered, ///< a response arrived, whatever its status, with Config::each_address from all + failed, ///< it was connected to, and no response could be had, or not from all addresses out_of_reach, ///< not connected to, its records say so or lead elsewhere, see plan_routes() not_found, ///< not connected to, the name does not exist lookup_failed, ///< not connected to, DNS gave no answer, so there is no telling @@ -122,6 +135,7 @@ struct Result std::string address; std::vector addresses; ///< with \c resolved: all of them, in the order of trying std::string error; ///< with \c failed: what went wrong on the last route + std::vector replies; ///< with Config::each_address, instead of the above std::vector notes; ///< records that could not be used, routes that did not work }; @@ -132,7 +146,8 @@ struct Result * whatever its status: a redirect is reported and not followed, nothing in the body is looked at, * and nothing else on the host is requested. The next route is for a connection that did not come * about: once a request has gone out, that was it for this host, however it ended. With - * Config::dns_only, it is the lookups and nothing else. + * Config::dns_only, it is the lookups and nothing else. With Config::each_address, all of this goes + * for each address of the host instead of for the host: one request per address, in order. * * Hosts are worked on Config::concurrency at a time, so results arrive as they are ready. All of * this is on the one executor, which is also what calls the observer. @@ -163,6 +178,9 @@ class Prober capy::task> work(); capy::task resolve_service(const std::string& host); capy::task probe(const std::string& host); + capy::task ask_address(const std::vector& routes, const std::string& host, + const corosio::endpoint& address, + std::vector& notes); capy::task> attempt(const Route& route, const std::string& host, const corosio::endpoint& endpoint, std::chrono::steady_clock::time_point deadline); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 837bbf8..745179e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -94,6 +94,17 @@ add_test(NAME http_probe_takes_dns_only set_tests_properties(http_probe_takes_dns_only PROPERTIES PASS_REGULAR_EXPRESSION "1 lookups failed") +add_test(NAME http_probe_refuses_each_address_with_dns_only + COMMAND http-probe --each-address --dns-only localhost) +set_tests_properties(http_probe_refuses_each_address_with_dns_only PROPERTIES + PASS_REGULAR_EXPRESSION "rules out --each-address") + +# An empty list gets as far as having no hosts, and goes nowhere. +add_test(NAME http_probe_takes_each_address_with_a_list + COMMAND http-probe --each-address --file /dev/null) +set_tests_properties(http_probe_takes_each_address_with_a_list PROPERTIES + PASS_REGULAR_EXPRESSION "no host names given") + # # The levels of logging, as far as they show without a connection: the lookups fail on a port of # loopback that nothing listens on, so this needs nothing but the program. diff --git a/test/output_test.cpp b/test/output_test.cpp index ea5791d..f02002d 100644 --- a/test/output_test.cpp +++ b/test/output_test.cpp @@ -114,6 +114,36 @@ TEST(Output, CountsTheHostsOnlyLookedUpOnlyIfThereAreAny) EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("resolved"))); } +TEST(Output, HasALinePerAddressWithARequestToEach) +{ + const Result result{.host = "example.com", + .outcome = Outcome::failed, + .records = {{.priority = 1, .target = ".", .params = {"alpn=h2"}}}, + .replies = {{.address = "2001:db8::1", .status = 200, .reason = "OK"}, + {.address = "192.0.2.1", .status = 299}, + {.address = "192.0.2.2", .error = "[h2 x:443] connect: no"}}}; + + EXPECT_EQ(format_result(result, false), "example.com: failed\n" + " record: HTTPS 1 . alpn=h2\n" + " address: 2001:db8::1 200 OK\n" + " address: 192.0.2.1 299\n" + " address: 192.0.2.2 failed: [h2 x:443] connect: no\n"); + + const auto text = format_result(result, true); + EXPECT_THAT(text, HasSubstr("address: \x1b[34m2001:db8::1\x1b[0m \x1b[32m200\x1b[0m OK")); + EXPECT_THAT(text, HasSubstr("\x1b[31mfailed\x1b[0m: [h2 x:443]")); +} + +TEST(Output, SaysThatAllAddressesAnswered) +{ + const Result result{.host = "example.com", + .outcome = Outcome::answered, + .replies = {{.address = "192.0.2.1", .status = 301, .reason = "Moved"}}}; + + EXPECT_EQ(format_result(result, false), "example.com: answered\n" + " address: 192.0.2.1 301 Moved\n"); +} + TEST(Output, ColoursTheWayCAresAsyncDoes) { const auto text = format_result(answered(), true); diff --git a/test/prober_test.cpp b/test/prober_test.cpp index 51a5085..c6fb602 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -1165,6 +1165,177 @@ TEST_F(Probe, DnsOnlyGoesWithH3Only) EXPECT_THAT(site.requests(), IsEmpty()); } +// ================================================================================================= +// --each-address +// ================================================================================================= + +// +// "localhost" is the one name the certificate is good for, and its addresses are up to /etc/hosts. +// The server listens on all of them, however many that is. +// +TEST_F(Probe, EachAddressSendsOneRequestToEachOfThem) +{ + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + ASSERT_THAT(results[0].replies, Not(IsEmpty())); + for (const auto& reply : results[0].replies) + { + EXPECT_THAT(reply.address, Not(IsEmpty())); + EXPECT_EQ(reply.status, 200); + EXPECT_EQ(reply.reason, "OK"); + EXPECT_THAT(reply.error, IsEmpty()); + } + EXPECT_EQ(site.requests().size(), results[0].replies.size()); +} + +TEST_F(Probe, EachAddressGoesOverHttp3) +{ + site.dns().add_https("localhost", 1, ".", h3_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + ASSERT_THAT(results[0].replies, Not(IsEmpty())); + EXPECT_EQ(results[0].replies[0].reason, "OK"); + EXPECT_EQ(site.requests().size(), results[0].replies.size()); +} + +TEST_F(Probe, EachAddressGoesOverHttp1) +{ + site.dns().add_https("localhost", 1, ".", h1_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + ASSERT_THAT(results[0].replies, Not(IsEmpty())); + EXPECT_EQ(results[0].replies[0].reason, "OK"); + EXPECT_EQ(site.requests().size(), results[0].replies.size()); +} + +TEST_F(Probe, EachAddressReportsARedirectAndDoesNotFollowIt) +{ + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.status = 301, .location = "/elsewhere"}); + site.page("/elsewhere", {.body = "not to be seen"}); + + auto configuration = config(); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + ASSERT_THAT(results[0].replies, Not(IsEmpty())); + for (const auto& reply : results[0].replies) + EXPECT_EQ(reply.status, 301); + EXPECT_THAT(site.targets(), testing::Each("/")); + EXPECT_EQ(site.requests().size(), results[0].replies.size()); +} + +TEST_F(Probe, EachAddressGoesOnToTheNextOneAfterAFailure) +{ + // + // The certificate is not for this name, so that both of them fail at connecting, in order. + // + site.dns().add_https("site.example.test", 1, ".", h2_at(site.port())); + site.dns().add_address("site.example.test", "127.0.0.1"); + site.dns().add_address("site.example.test", "::1"); + + auto configuration = config({"site.example.test"}); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::failed); + ASSERT_EQ(results[0].replies.size(), 2u); + EXPECT_EQ(results[0].replies[0].address, "::1"); + EXPECT_EQ(results[0].replies[1].address, "127.0.0.1"); + EXPECT_THAT(results[0].replies[0].error, HasSubstr("connect")); + EXPECT_THAT(results[0].replies[1].error, HasSubstr("connect")); + EXPECT_THAT(site.requests(), IsEmpty()); +} + +TEST_F(Probe, EachAddressTriesTheNextRouteOnlyForAConnectionThatFailed) +{ + std::uint16_t closed = 0; + { + auto acceptor = silent_tcp_acceptor(); + closed = acceptor.local_endpoint().port(); + } + site.dns().add_https("localhost", 1, ".", h2_at(closed)); + site.dns().add_https("localhost", 2, ".", h2_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + ASSERT_THAT(results[0].replies, Not(IsEmpty())); + for (const auto& reply : results[0].replies) + EXPECT_EQ(reply.status, 200) << reply.error; + EXPECT_EQ(site.requests().size(), results[0].replies.size()); +} + +TEST_F(Probe, EachAddressTriesNoOtherRouteOnceARequestHasGoneOut) +{ + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.dns().add_https("localhost", 2, ".", h1_at(site.port())); + site.page("/", {.body = "too late", .delay = 400ms}); + + auto configuration = config(); + configuration.timeout = 100ms; + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::failed); + ASSERT_THAT(results[0].replies, Not(IsEmpty())); + for (const auto& reply : results[0].replies) + EXPECT_THAT(reply.error, HasSubstr("timed out")); + EXPECT_EQ(site.requests().size(), results[0].replies.size()); +} + +TEST_F(Probe, EachAddressStillSaysThatARecordLeadsElsewhere) +{ + site.dns().add_https("localhost", 1, "elsewhere.example.test", h2_at(site.port())); + + auto configuration = config(); + configuration.each_address = true; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::out_of_reach); + EXPECT_THAT(results[0].replies, IsEmpty()); + EXPECT_THAT(site.requests(), IsEmpty()); +} + +TEST_F(Probe, EachAddressDoesNotGoWithDnsOnly) +{ + auto configuration = config(); + configuration.each_address = true; + configuration.dns_only = true; + + EXPECT_THROW(Prober(context.get_executor(), std::move(configuration), [](const Result&) {}), + std::invalid_argument); + finish(); +} + // ================================================================================================= // --dot // ================================================================================================= From 7df728c83184802109f826e89db5e83bfb4689dc Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Fri, 6 Nov 2026 16:25:44 +0000 Subject: [PATCH 4/5] fix: update base image version to 0.32 --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5eff7b1..2cfc70b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,7 +2,7 @@ # The dependencies of this project have been moved into another Dockerfile that can be built # separately. This image can be pushed to the HUB, speeding up codespace creation considerably. # -FROM docker.io/psedoc/anyhttp:0.31 +FROM docker.io/psedoc/anyhttp:0.32 # ================================================================================================== From c9eb73e578a0c0159dbc8ca03ccc1f51cb426634 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Fri, 6 Nov 2026 21:43:18 +0000 Subject: [PATCH 5/5] feat: add aliases and configuration setup for btop and htop --- .devcontainer/.bash_aliases | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.devcontainer/.bash_aliases b/.devcontainer/.bash_aliases index ac524bd..3bf3838 100644 --- a/.devcontainer/.bash_aliases +++ b/.devcontainer/.bash_aliases @@ -3,3 +3,9 @@ alias gs="git status" alias gd="git diff" alias gds="git diff --staged" alias tma="tmux attach" + +mkdir -p ~/.config/{btop,htop} +[[ -f ~/.config/btop/btop.conf ]] || ln -sf /commandhistory/btop.conf ~/.config/btop/ +[[ -f ~/.config/htop/htoprc ]] || ln -sf /commandhistory/htoprc ~/.config/htop/ + +[[ -f /commandhistory/.bashrc-pgit-bot ]] && source /commandhistory/.bashrc-pgit-bot