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 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 # ================================================================================================== diff --git a/CLAUDE.md b/CLAUDE.md index 1404155..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. @@ -45,7 +46,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. diff --git a/README.md b/README.md index 1a7918a..849b30d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ 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 | +| 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 @@ -49,6 +51,38 @@ 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. + +**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 a803f7f..6eddd78 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -160,6 +160,12 @@ 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("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 d332696..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)); @@ -117,10 +136,18 @@ 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& 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) @@ -137,11 +164,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..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) @@ -393,6 +395,27 @@ 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; + } + + 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) { @@ -432,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 9247c02..bec1950 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -70,6 +70,14 @@ 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; + + /// 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(); @@ -89,17 +97,27 @@ 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 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,7 +133,9 @@ 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 replies; ///< with Config::each_address, instead of the above std::vector notes; ///< records that could not be used, routes that did not work }; @@ -125,7 +145,9 @@ 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. 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. @@ -156,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 1bd0cee..745179e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -89,6 +89,22 @@ 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") + +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 b8a4a35..f02002d 100644 --- a/test/output_test.cpp +++ b/test/output_test.cpp @@ -88,6 +88,62 @@ 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, 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 778d78c..c6fb602 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -1048,6 +1048,294 @@ 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()); +} + +// ================================================================================================= +// --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 // =================================================================================================