diff --git a/.clang-tidy b/.clang-tidy index a5e47ca..2cf5c3f 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,9 +1,6 @@ # clang-tidy config file --- -Checks: 'clang-diagnostic-*,clang-analyzer-*,modernize*,performance*,readability-*size*,readability*redundant*,-readability-redundant-member-init,-readability-redundant-access-specifiers,misc*,boost-use-to-string,cert*,google-explicit-constructor,-cert-err58-cpp,-modernize-concat-nested-namespaces,-modernize-use-nodiscard,-misc-non-private-member-variables-in-classes,-modernize-use-trailing-return-type,-misc-no-recursion,-misc-const-correctness,-misc-use-anonymous-namespace,-performance-avoid-endl' -# note DEV: -# disabled 'hicpp-signed-bitwise' because it's too noisy -# (IgnorePositiveIntegerLiterals doesn't seem to work, re-evaluate with Clang 11+) +Checks: 'clang-diagnostic-*,clang-analyzer-*,modernize*,performance*,readability-*size*,readability*redundant*,-readability-redundant-member-init,-readability-redundant-access-specifiers,misc*,boost-use-to-string,cert*,google-explicit-constructor,-cert-err58-cpp,-modernize-use-nodiscard,-misc-non-private-member-variables-in-classes,-modernize-use-trailing-return-type,-misc-no-recursion,-misc-const-correctness,-misc-use-anonymous-namespace,-readability-redundant-qualified-alias,-performance-enum-size' WarningsAsErrors: '' HeaderFilterRegex: '' @@ -22,4 +19,6 @@ CheckOptions: value: llvm - key: modernize-use-nullptr.NullMacros value: 'NULL' + - key: performance-unnecessary-value-param.AllowedTypes + value: 'std::(__[A-Za-z0-9_]+::)*exception_ptr$' ... diff --git a/README.md b/README.md index 849b30d..bcd6c43 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ It looks up the HTTPS records of each host in DNS ([RFC 9460](https://www.rfc-ed and requests `https:///` once, over TLS, in the way that the records say. It prints the status, the route, the IP address it connected to, the time it took and the header fields of the response, along with the host's records. A redirect is reported and not followed, and nothing else on the host -is requested. [Here is what that looks like](doc/README.md). +is requested. Its `Location` goes at the end of the status line, as in `301 … -> https://www.example.com/`, +as it came: not resolved against the URL, and not looked up. [Here is what that looks like](doc/README.md). There is output for every host but those that do not exist, which `--all` adds. The counts at the end look like this, on stderr: @@ -37,7 +38,7 @@ look like this, on stderr: | has a record that says the service is not available | out of reach, not connected to | | 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 | +| offers none of the protocols of `--h3`, `--h2` and `--h1`, with any of them | left out, 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 | @@ -46,10 +47,17 @@ record, HTTP/3 goes first, and then TCP on the same port: HTTP/2 if the record h HTTP/1.1, which is in every record that does not say `no-default-alpn`. The route in the output is what the server went for, which may be HTTP/1.1 where HTTP/2 was offered. -**With `--h3`,** only hosts with a compatible record that has `h3` in its `alpn` go ahead, on whichever -target, and they are treated as above, so that one can still end up out of reach, or going over TCP -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 `--h3`, `--h2` or `--h1`,** only the protocols given are spoken, in any combination, and all +three without any of them. HTTP/3 needs a compatible record with `h3` in its `alpn`. HTTP/2 and +HTTP/1.1 need a record that offers them, or no compatible record at all, which means port 443. A host +that offers none of the protocols given, on whichever target, is left out after its HTTPS lookup, +printed only with `--all` and with a note on why, and counted at the end as `5 left out`. The others +are treated as above, with the routes of those protocols only, so that one can still end up out of +reach. With `--h3` alone, that means no going over TCP when HTTP/3 can not be connected to. + +anyhttp offers HTTP/1.1 along with HTTP/2, and a server may take it, or not use ALPN at all, which +comes to the same. With `--h2` but not `--h1`, such a server is not sent the request. That counts as a +connection that failed, so the next address or route gets its turn. **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 @@ -64,7 +72,8 @@ example.com: resolved 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. +The counts at the end then add how many there were, as `5 resolved`, and `--h3`, `--h2` and `--h1` +work 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 @@ -79,9 +88,12 @@ example.com: answered 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 +per host, and it can't go with `--dns-only`. The addresses are all asked at the same time, so a host +takes as long as its slowest address, and they are printed in the order of trying them all the same. +`--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. +have no reason phrase, so there the `OK` is what that status is called. A redirect has its `Location` +at the end of the line, as in `301 Moved Permanently -> /elsewhere`, and the header fields are left out. **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/doc/README.md b/doc/README.md index 9ac5edd..873164e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -184,7 +184,7 @@ The two tokens in `set-cookie` and `report-to` are cut short to `…`. 21:01:20.687 debug [h3:[2606:4700::6810:84e5]:443] write_pkt: nghttp3_conn_writev_stream -> stream=-1 sveccnt=0 fin=0 --- 62 ------------------------------------------------------------------------ 21:01:20.688 info [client] dtor -cloudflare.com: 301 [h3 cloudflare.com:443] at 2606:4700::6810:84e5, 167 bytes in 82ms +cloudflare.com: 301 [h3 cloudflare.com:443] at 2606:4700::6810:84e5, 167 bytes in 82ms -> https://www.cloudflare.com/ record: HTTPS 1 . alpn=h3,h2 ipv4hint=104.16.132.229,104.16.133.229 ipv6hint=2606:4700::6810:84e5,2606:4700::6810:85e5 content-type: text/html; charset=UTF-8 date: Tue, 03 Nov 2026 21:01:21 GMT diff --git a/src/main.cpp b/src/main.cpp index 6eddd78..05464a5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,7 +6,12 @@ #include #include -#include +#include +#include +#include +#include +#include +#include #include #include @@ -158,8 +163,14 @@ po::options_description describe_options(Arguments& arguments) config.timeout = std::chrono::milliseconds(not_negative("--timeout-ms", value)); }), "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("h3", po::bool_switch(&config.protocols.h3), + "speak HTTP/3, where an HTTPS record offers it. Of --h3, --h2 and --h1, only those given " + "are spoken, all of them without any, and a host that offers none of them is left out"); + opts("h2", po::bool_switch(&config.protocols.h2), + "speak HTTP/2, over TCP where a record offers it or there is none. Without --h1, a server " + "that chooses HTTP/1.1 is not sent the request"); + opts("h1", po::bool_switch(&config.protocols.h1), + "speak HTTP/1.1, over TCP where a record offers it or there is none"); 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"); @@ -167,7 +178,7 @@ po::options_description describe_options(Arguments& arguments) "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"); + "also print the hosts that do not exist, and those that --h3, --h2 or --h1 leave out"); opts("color", po::bool_switch(&arguments.color), "colour the output even where it does not go to a terminal, e.g. into 'less -R'"); opts("verbose,v", po::value>()->zero_tokens()->composing(), @@ -186,7 +197,7 @@ void print_usage(std::ostream& out, const po::options_description& options) << wrap(exit_code, columns) << '\n'; } -void parse_arguments(int argc, char* argv[], const po::options_description& options, +void parse_arguments(int argc, char** argv, const po::options_description& options, Arguments& arguments) { try @@ -305,10 +316,10 @@ int main(int argc, char* argv[]) ++counts[result.outcome]; if (!result.records.empty() || result.aliased) ++with_records; - if (all || (result.outcome != Outcome::not_found && result.outcome != Outcome::no_h3)) + if (all || (result.outcome != Outcome::not_found && result.outcome != Outcome::left_out)) { std::print("{}", http_probe::format_result(result, color_results)); - std::fflush(stdout); // a run can take a while, and it is worth watching + std::ignore = std::fflush(stdout); // a run can take a while, and it is worth watching } }; @@ -320,9 +331,9 @@ int main(int argc, char* argv[]) // What a handler throws ends the process, so this one leaves it for after run(). // boost::capy::run_async( - context.get_executor(), []() noexcept {}, + context.get_executor(), [] noexcept {}, [&](std::exception_ptr ex) noexcept { - failed = ex; + failed = std::move(ex); context.stop(); })(prober.run()); if (verbosity >= 2) diff --git a/src/output.cpp b/src/output.cpp index 6cb60ce..0e66a86 100644 --- a/src/output.cpp +++ b/src/output.cpp @@ -43,6 +43,14 @@ std::string address_text(std::string_view address, bool color) return colored(address, v6 ? color_of::v6 : color_of::v4, color); } +// +// Where a redirect points, as the server put it, or nothing. +// +std::string location_text(std::string_view location) +{ + return location.empty() ? std::string() : std::format(" -> {}", location); +} + // // 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. @@ -51,9 +59,9 @@ 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); + return std::format("{}{}{}", status_text(reply.status, color), + reply.reason.empty() ? "" : std::format(" {}", reply.reason), + location_text(reply.location)); } // @@ -108,12 +116,12 @@ std::string format_result(const Result& result, bool color) switch (result.outcome) { case Outcome::answered: - 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); + 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, location_text(result.location)); break; case Outcome::failed: text = !result.replies.empty() @@ -133,8 +141,8 @@ std::string format_result(const Result& result, bool color) case Outcome::lookup_failed: text = std::format("{}: {}\n", host, colored("lookup failed", color_of::bad, color)); break; - case Outcome::no_h3: - text = std::format("{}: no h3 record\n", host); + case Outcome::left_out: + text = std::format("{}: left out\n", host); break; case Outcome::resolved: text = std::format("{}: {}\n", host, colored("resolved", color_of::good, color)); @@ -168,7 +176,7 @@ std::string format_summary(Counts counts, bool color) 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::left_out] ? std::format(", {} left out", counts[Outcome::left_out]) : "", counts[Outcome::resolved] ? std::format(", {}", count(Outcome::resolved, "resolved", color_of::good)) : ""); diff --git a/src/prober.cpp b/src/prober.cpp index 76074cb..c8b2880 100644 --- a/src/prober.cpp +++ b/src/prober.cpp @@ -10,12 +10,11 @@ #include #include -#include - #include #include #include #include +#include #include #include #include @@ -36,6 +35,17 @@ constexpr std::string_view user_agent = "http-probe/0.0.1 (+https://github.com/p constexpr int max_alias_chain = 8; +// +// Where a redirect points, as the server put it: nothing is resolved against the URL that was +// asked for, and nothing is requested. Empty for anything but a redirect. +// +std::string redirect_location(const anyhttp::client::Message& message) +{ + const auto status = message.result_int(); + return status >= 300 && status < 400 ? std::string(message[http::field::location]) + : std::string(); +} + // // The resolver does not write anywhere by itself. Its messages go where our own go. // @@ -99,7 +109,7 @@ std::optional server_name(std::string_view server) (!port.empty() && (port.size() == 1 || !std::ranges::all_of(port.substr(1), is_digit)))) return std::nullopt; - return ServerName{name, port}; + return ServerName{.name = name, .port = port}; } // @@ -181,7 +191,7 @@ void prefer_ipv6(std::vector& addresses) std::ranges::stable_partition(addresses, &corosio::endpoint::is_v6); } -Prober::Prober(capy::any_executor executor, Config config, Observer observer) +Prober::Prober(const capy::any_executor& executor, Config config, Observer observer) : executor_(executor), config_(std::move(config)), observer_(std::move(observer)) { if (config_.hosts.empty()) @@ -354,22 +364,16 @@ capy::task Prober::probe(const std::string& host) result.aliased = found.aliased; result.records = found.records; - const auto plan = plan_routes(host, found, config_.default_port); + const auto plan = plan_routes(host, found, config_.protocols, config_.default_port); result.notes = plan.rejected; for (const auto& rejected : plan.rejected) config_.logger->debug("{}: rejected: {}", host, rejected); - if (config_.h3_only && !found.lookup_failed && !found.not_found && !has_h3_record(found)) - { - config_.logger->debug("{}: left out, no record offers HTTP/3", host); - result.outcome = Result::Outcome::no_h3; - co_return result; - } - if (plan.routes.empty()) { result.outcome = found.lookup_failed ? Result::Outcome::lookup_failed : found.not_found ? Result::Outcome::not_found + : plan.left_out ? Result::Outcome::left_out : Result::Outcome::out_of_reach; co_return result; } @@ -403,16 +407,31 @@ capy::task Prober::probe(const std::string& host) co_return result; } + // + // All addresses at the same time, as each of them gets a request of its own anyway, so that a + // host takes as long as its slowest address and not as long as all of them together. The replies + // and the notes are in the order of the addresses all the same. + // if (config_.each_address) { + std::vector> notes(addresses.size()); + std::vector>> asks; + asks.reserve(addresses.size()); + for (std::size_t i = 0; i < addresses.size(); ++i) + asks.push_back(ask_address(plan.routes, host, addresses[i], notes[i])); + + auto [ec, replies] = co_await capy::when_all(std::move(asks)); + if (ec) // never, see ask_address() + throw std::logic_error(std::format("{}: {}", host, ec.message())); + result.outcome = Result::Outcome::answered; - for (const auto& address : addresses) + for (std::size_t i = 0; i < replies.size(); ++i) { - auto reply = co_await ask_address(plan.routes, host, address, result.notes); - if (!reply.error.empty()) + if (!replies[i].error.empty()) result.outcome = Result::Outcome::failed; - result.replies.push_back(std::move(reply)); + std::ranges::move(notes[i], std::back_inserter(result.notes)); } + result.replies = std::move(replies); co_return result; } @@ -442,6 +461,7 @@ capy::task Prober::probe(const std::string& host) result.outcome = Result::Outcome::answered; result.error.clear(); result.status = static_cast(response.message.result_int()); + result.location = redirect_location(response.message); auto spoken = route; spoken.protocol = response.protocol; result.route = spoken.to_string(); @@ -459,10 +479,13 @@ capy::task Prober::probe(const std::string& host) // 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) +// What went wrong is in the reply, and the error code always success: when_all() would take one +// for a reason to stop the others, which have nothing to do with this address. +// +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) @@ -476,17 +499,18 @@ capy::task Prober::ask_address(const std::vector& routes, { reply.status = static_cast(response.message.result_int()); reply.reason = response.message.reason(); + reply.location = redirect_location(response.message); reply.error.clear(); - co_return reply; + co_return {std::error_code(), std::move(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; + break; } - co_return reply; + co_return {std::error_code(), std::move(reply)}; } // @@ -559,6 +583,20 @@ capy::task> Prober::exchange(anyhttp::client::Cl session = std::move(connected); + // + // anyhttp's h2 offers HTTP/1.1 as well, and a server that does not use ALPN at all gets it, + // too. Where that is not one to speak, nothing is asked, and this counts as a connection that + // failed, for the next address or route to have a go. + // + if (const auto protocol = session.protocol(); !config_.protocols.speaks(protocol)) + { + if (auto [shutdown_ec] = co_await session.shutdown(); shutdown_ec) + config_.logger->debug("{}: shutdown: {}", url.host(), shutdown_ec.message()); + co_return {std::make_error_code(std::errc::protocol_not_supported), + Response{.error = std::format("connect: the server chose {}, so no request", + protocol_name(protocol))}}; + } + anyhttp::Fields headers; headers.set(http::field::user_agent, user_agent); request_sent = true; diff --git a/src/prober.hpp b/src/prober.hpp index bec1950..fb520e8 100644 --- a/src/prober.hpp +++ b/src/prober.hpp @@ -66,16 +66,18 @@ struct Config /// Where to go for HTTPS when the records don't say. Only tests have a use for anything else. std::uint16_t default_port = 443; - /// Connect only to the hosts with a record that offers HTTP/3, see has_h3_record(). Those are - /// treated as usual, which includes going over TCP if HTTP/3 can not be connected to. - bool h3_only = false; + /// The protocols to speak, and no others, see plan_routes(): a host that offers none of them is + /// left out. A server that takes HTTP/1.1 where h2 was offered is not sent the request without + /// h1 among them. + Protocols protocols; /// 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. + /// Send a request to each address of a host, all of them at the same 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 @@ -103,6 +105,7 @@ 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 location; ///< with a 3xx status: the Location field as it came, see Result::location std::string error; ///< what went wrong on the last route, if no response could be had }; @@ -116,7 +119,7 @@ struct Result 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 + left_out, ///< not connected to, it offers none of Config::protocols resolved, ///< not connected to, but it could have been tried, see Config::dns_only }; @@ -128,6 +131,9 @@ struct Result int status = 0; ///< with \c answered std::string route; ///< with \c answered: what the response came over, as the server chose it std::vector> fields; ///< with \c answered + /// With \c answered and a 3xx status: the Location field as it came, neither resolved nor + /// followed, and empty without one. + std::string location; std::size_t body_size = 0; ///< with \c answered std::chrono::milliseconds duration{}; ///< with \c answered: of the attempt that got through @@ -147,7 +153,8 @@ struct Result * 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. 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. + * for each address of the host instead of for the host: one request per address, to all of them at + * the same time, with the replies in the order of the addresses. * * 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. @@ -160,7 +167,7 @@ class Prober /// Throws \c std::invalid_argument for a configuration that is not good to go, and does nothing /// else until run(), which has more to say about the DNS servers: the same for a list that can't /// be used, and \c std::runtime_error for a host name in it that can't be looked up. - Prober(capy::any_executor executor, Config config, Observer observer); + Prober(const capy::any_executor& executor, Config config, Observer observer); capy::task<> run(); @@ -178,9 +185,10 @@ 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> 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/src/routes.cpp b/src/routes.cpp index 1fc85a1..64c73d9 100644 --- a/src/routes.cpp +++ b/src/routes.cpp @@ -28,18 +28,19 @@ struct Service bool offers(std::string_view protocol) const { return std::ranges::contains(alpn, protocol); } // - // What to connect with, in the order to try them, which is at most one connection per transport. - // anyhttp's h2 offers HTTP/1.1 along with it, so that a server still has the choice over TCP. + // What to connect with, of those to speak, in the order to try them, which is at most one + // connection per transport. anyhttp's h2 offers HTTP/1.1 along with it, so that a server still + // has the choice over TCP. Where h1 is not to be spoken, the prober does not take that choice. // - std::vector protocols() const + std::vector protocols(const Protocols& to_speak = {}) const { using enum anyhttp::Protocol; std::vector result; - if (offers("h3")) + if (offers("h3") && to_speak.speaks(h3)) result.push_back(h3); - if (offers("h2")) + if (offers("h2") && to_speak.speaks(h2)) result.push_back(h2); - else if (offers("http/1.1")) + else if (offers("http/1.1") && to_speak.speaks(h1)) result.push_back(h1); return result; } @@ -122,29 +123,63 @@ std::string to_text(const c_ares_async::https_record& record) return text; } -std::string Route::to_string() const +std::string_view protocol_name(anyhttp::Protocol protocol) { using enum anyhttp::Protocol; - return std::format("{} {}:{}", protocol == h3 ? "h3" : protocol == h2 ? "h2" : "h1", host, port); + return protocol == h3 ? "h3" : protocol == h2 ? "h2" : "h1"; +} + +// +// No 'using enum' in the members, which would hide the fields of the same names. +// +bool Protocols::speaks(anyhttp::Protocol protocol) const +{ + if (!h3 && !h2 && !h1) + return true; + switch (protocol) + { + case anyhttp::Protocol::h3: + return h3; + case anyhttp::Protocol::h2: + return h2; + case anyhttp::Protocol::h1: + return h1; + } + return false; +} + +bool Protocols::speaks_tcp() const +{ + return speaks(anyhttp::Protocol::h2) || speaks(anyhttp::Protocol::h1); } -Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16_t default_port) +std::string Protocols::to_string() const +{ + std::string text; + for (const auto protocol : {anyhttp::Protocol::h3, anyhttp::Protocol::h2, anyhttp::Protocol::h1}) + if (speaks(protocol)) + text += std::format("{}{}", text.empty() ? "" : " or ", protocol_name(protocol)); + return text; +} + +std::string Route::to_string() const +{ + return std::format("{} {}:{}", protocol_name(protocol), host, port); +} + +Plan plan_routes(std::string_view host, const ServiceRecords& found, const Protocols& protocols, + std::uint16_t default_port) { Plan plan{.rejected = found.notes}; if (found.lookup_failed || found.not_found) return plan; - if (found.unavailable) - { - plan.out_of_reach = true; - return plan; - } - auto records = found.records; std::ranges::stable_sort(records, {}, &c_ares_async::https_record::priority); - bool any_compatible = false; + bool any_compatible = false; // a record to take seriously, with a protocol that anyhttp speaks + bool any_to_speak = false; // one of those that offers a protocol to speak, wherever it points for (const auto& record : records) { const auto text = to_text(record); @@ -155,14 +190,21 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 continue; } - const auto protocols = service.protocols(); - if (protocols.empty()) + if (service.protocols().empty()) { plan.rejected.push_back(std::format("{}: no protocol that anyhttp speaks", text)); continue; } any_compatible = true; + const auto to_speak = service.protocols(protocols); + if (to_speak.empty()) + { + plan.rejected.push_back(std::format("{}: offers no {}", text, protocols.to_string())); + continue; + } + any_to_speak = true; + const auto target = normalize_host(is_own_name(record.target) ? found.owner : record.target); if (target != host) { @@ -172,7 +214,7 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 continue; } - for (const auto protocol : protocols) + for (const auto protocol : to_speak) { Route route{.protocol = protocol, .host = target, @@ -189,6 +231,25 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 if (!plan.routes.empty()) return plan; + // + // Not one of the protocols to speak, wherever it would have been. Without a usable record, what + // there is is TCP, and that goes for one who says that the service is not available, too. + // + if (any_compatible ? !any_to_speak : !protocols.speaks_tcp()) + { + if (!any_compatible) + plan.rejected.push_back( + std::format("no usable HTTPS record offers {}", protocols.to_string())); + plan.left_out = true; + return plan; + } + + if (found.unavailable) + { + plan.out_of_reach = true; + return plan; + } + // // After an alias, the name it led to is an endpoint even without any parameters (section 3). // @@ -203,21 +264,14 @@ Plan plan_routes(std::string_view host, const ServiceRecords& found, std::uint16 return plan; } - plan.routes.push_back(Route{.protocol = anyhttp::Protocol::h2, + using enum anyhttp::Protocol; + plan.routes.push_back(Route{.protocol = protocols.speaks(h2) ? h2 : h1, .host = std::string(host), .port = default_port, .source = "no usable HTTPS record"}); return plan; } -bool has_h3_record(const ServiceRecords& found) -{ - return std::ranges::any_of(found.records, [](const c_ares_async::https_record& record) { - const auto service = parse_service(record); - return !service.incompatible && service.offers("h3"); - }); -} - // ================================================================================================= } // namespace http_probe diff --git a/src/routes.hpp b/src/routes.hpp index c854bfd..ecd9b28 100644 --- a/src/routes.hpp +++ b/src/routes.hpp @@ -38,6 +38,22 @@ struct ServiceRecords bool unavailable = false; ///< an alias to "." says that the service is not available }; +/// "h3", "h2" or "h1", as in the options and the routes. +std::string_view protocol_name(anyhttp::Protocol protocol); + +/// The protocols to speak, as --h3, --h2 and --h1 say. None of them means all of them. +struct Protocols +{ + bool h3 = false; + bool h2 = false; + bool h1 = false; + + bool speaks(anyhttp::Protocol protocol) const; + bool speaks_tcp() const; ///< h2 or h1, which is what a host without a usable record gets + + std::string to_string() const; ///< "h3 or h2" +}; + /// One way of connecting, always over TLS. The TLS name and the authority stay the host's. struct Route { @@ -55,10 +71,11 @@ struct Plan std::vector routes; ///< in the order to try them; empty if there is no usable way std::vector rejected; ///< what the records offered, or we knew of, and why not bool out_of_reach = false; ///< the records say not to connect, or only to a target we can't use + bool left_out = false; ///< what the host offers is none of the protocols to speak }; /** - * Says how to connect to https://\p host/, given what was found for it. + * Says how to connect to https://\p host/, given what was found for it, with \p protocols only. * * Records go by priority, and within a record, HTTP/3 comes ahead of TCP. A name without a usable * record, the most common thing there is, gets \p default_port over TCP, and so does a record that @@ -68,12 +85,12 @@ struct Plan * wants that to be the origin, so a record is usable only if it stays on the host's own name. If * none of them does, the host is out of reach: the records do say where to go, and it is not here. * A name that does not exist and a lookup that failed have no routes either. + * + * Before any of that, the host is left out if none of its records that have to be taken seriously + * (section 8) offers one of \p protocols, wherever it points. Without such a record, it offers TCP. */ Plan plan_routes(std::string_view host, const ServiceRecords& found, - std::uint16_t default_port = 443); - -/// Whether a record that has to be taken seriously (section 8) offers HTTP/3, wherever it points. -bool has_h3_record(const ServiceRecords& found); + const Protocols& protocols = {}, std::uint16_t default_port = 443); // ================================================================================================= diff --git a/test/demo_site.cpp b/test/demo_site.cpp index 41980fa..063e629 100644 --- a/test/demo_site.cpp +++ b/test/demo_site.cpp @@ -32,16 +32,16 @@ int main() http_probe::test::Site site(context.get_executor()); site.dns().add_https("localhost", 1, ".", - {{ARES_SVCB_PARAM_ALPN, {2, 'h', '3'}}, - {ARES_SVCB_PARAM_PORT, - {static_cast(site.port() >> 8), - static_cast(site.port() & 0xff)}}}); + {{.key = ARES_SVCB_PARAM_ALPN, .value = {2, 'h', '3'}}, + {.key = ARES_SVCB_PARAM_PORT, + .value = {static_cast(site.port() >> 8), + static_cast(site.port() & 0xff)}}}); site.page("/", {.body = "

hello

"}); std::println("http-probe --dns-server {} --ca-file {} localhost", site.dns_address(), site.ca_file().string()); - std::fflush(stdout); + (void)std::fflush(stdout); corosio::signal_set signals(context, SIGINT, SIGTERM); capy::run_async(context.get_executor())(stop_on_signal(signals, site)); diff --git a/test/output_test.cpp b/test/output_test.cpp index f02002d..2137d14 100644 --- a/test/output_test.cpp +++ b/test/output_test.cpp @@ -40,6 +40,30 @@ TEST(Output, SaysWhatCameBack) " server: test\n"); } +TEST(Output, SaysWhereARedirectPoints) +{ + auto result = answered(301); + result.location = "https://www.example.com/"; + EXPECT_EQ(format_result(result, false), + "example.com: 301 [h3 example.com:443] at 192.0.2.1, 5 bytes in 12ms" + " -> https://www.example.com/\n" + " record: HTTPS 1 . alpn=h3 ipv4hint=192.0.2.1 ipv6hint=2001:db8::1\n" + " server: test\n"); +} + +TEST(Output, SaysWhereARedirectPointsForEachAddress) +{ + const Result result{ + .host = "example.com", + .outcome = Outcome::answered, + .replies = {{.address = "2001:db8::1", .status = 308, .location = "/elsewhere"}, + {.address = "192.0.2.1", .status = 301, .reason = "Moved", .location = "/b"}}}; + + EXPECT_EQ(format_result(result, false), "example.com: answered\n" + " address: 2001:db8::1 308 -> /elsewhere\n" + " address: 192.0.2.1 301 Moved -> /b\n"); +} + TEST(Output, SaysWhichAddressFailed) { EXPECT_EQ(format_result({.host = "example.com", @@ -77,15 +101,15 @@ TEST(Output, SaysWhyThereIsNoResponse) TEST(Output, SaysThatAHostWasLeftOut) { - EXPECT_EQ(format_result({.host = "example.com", .outcome = Outcome::no_h3}, false), - "example.com: no h3 record\n"); + EXPECT_EQ(format_result({.host = "example.com", .outcome = Outcome::left_out}, false), + "example.com: left out\n"); } TEST(Output, CountsTheHostsLeftOutOnlyIfThereAreAny) { - EXPECT_EQ(format_summary({{Outcome::answered, 3}, {Outcome::no_h3, 5}}, false), - "3 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 without h3"); - EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("h3"))); + EXPECT_EQ(format_summary({{Outcome::answered, 3}, {Outcome::left_out, 5}}, false), + "3 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 left out"); + EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("left out"))); } TEST(Output, ListsTheAddressesOfAHostThatWasOnlyLookedUp) @@ -108,8 +132,8 @@ TEST(Output, ListsTheAddressesOfAHostThatWasOnlyLookedUp) 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, " + EXPECT_EQ(format_summary({{Outcome::resolved, 3}, {Outcome::left_out, 5}}, false), + "0 answered, 0 failed, 0 out of reach, 0 not found, 0 lookups failed, 5 left out, " "3 resolved"); EXPECT_THAT(format_summary({{Outcome::answered, 3}}, false), Not(HasSubstr("resolved"))); } diff --git a/test/prober_test.cpp b/test/prober_test.cpp index c6fb602..5c3d065 100644 --- a/test/prober_test.cpp +++ b/test/prober_test.cpp @@ -5,12 +5,17 @@ #include "prober.hpp" #include "site.hpp" +#include #include #include +#include #include #include #include +#include #include +#include +#include #include #include @@ -21,6 +26,8 @@ #include +#include +#include #include #include #include @@ -30,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -54,7 +62,7 @@ namespace boost::corosio { /// What a failed expectation shows of an endpoint: Corosio has no output operator for one. -inline void PrintTo(const endpoint& endpoint, std::ostream* out) +static void PrintTo(const endpoint& endpoint, std::ostream* out) { *out << endpoint.address() << ':' << endpoint.port(); } @@ -74,15 +82,17 @@ corosio::ip_address make_address(std::string_view text) fake_dns_server::svc_param port_param(std::uint16_t port) { - return {ARES_SVCB_PARAM_PORT, - {static_cast(port >> 8), static_cast(port & 0xff)}}; + return { + .key = ARES_SVCB_PARAM_PORT, + .value = {static_cast(port >> 8), static_cast(port & 0xff)}}; } -const fake_dns_server::svc_param alpn_h3{ARES_SVCB_PARAM_ALPN, {2, 'h', '3'}}; -const fake_dns_server::svc_param alpn_h2{ARES_SVCB_PARAM_ALPN, {2, 'h', '2'}}; -const fake_dns_server::svc_param alpn_h1{ARES_SVCB_PARAM_ALPN, - {8, 'h', 't', 't', 'p', '/', '1', '.', '1'}}; -const fake_dns_server::svc_param no_default_alpn{ARES_SVCB_PARAM_NO_DEFAULT_ALPN, {}}; +const fake_dns_server::svc_param alpn_h3{.key = ARES_SVCB_PARAM_ALPN, .value = {2, 'h', '3'}}; +const fake_dns_server::svc_param alpn_h2{.key = ARES_SVCB_PARAM_ALPN, .value = {2, 'h', '2'}}; +const fake_dns_server::svc_param alpn_h1{.key = ARES_SVCB_PARAM_ALPN, + .value = {8, 'h', 't', 't', 'p', '/', '1', '.', '1'}}; +const fake_dns_server::svc_param no_default_alpn{.key = ARES_SVCB_PARAM_NO_DEFAULT_ALPN, + .value = {}}; // // One protocol and no other, for a test to know which one it is getting. A record with nothing but @@ -117,16 +127,16 @@ class Probe : public testing::Test // // In a variable: the coroutine refers to what the lambda captured, for as long as it runs. // - const auto run = [&]() -> capy::task<> { + const auto run = [&] -> capy::task<> { co_await prober.run(); stop_sites(); }; std::exception_ptr failed; capy::run_async( - context.get_executor(), []() noexcept {}, + context.get_executor(), [] noexcept {}, [&](std::exception_ptr ex) noexcept { - failed = ex; + failed = std::move(ex); context.stop(); })(run()); context.run(); @@ -149,7 +159,7 @@ class Probe : public testing::Test corosio::tcp_acceptor silent_tcp_acceptor() { - return corosio::tcp_acceptor(context, {corosio::ipv6_address::any(), 0}); + return {context, {corosio::ipv6_address::any(), 0}}; } // @@ -289,6 +299,7 @@ TEST_F(Probe, ReportsARedirectAndDoesNotFollowIt) ASSERT_EQ(results.size(), 1u); EXPECT_EQ(results[0].status, 302); EXPECT_THAT(results[0].fields, Contains(Pair("location", "/moved"))); + EXPECT_EQ(results[0].location, "/moved"); // as it came, not resolved EXPECT_THAT(site.targets(), ElementsAre("/")); } @@ -475,6 +486,7 @@ TEST_F(Probe, DoesNotFollowARedirectOverHttp2) ASSERT_EQ(results.size(), 1u); EXPECT_EQ(results[0].status, 302) << notes(results[0]); EXPECT_THAT(results[0].fields, Contains(Pair("location", "/moved"))); + EXPECT_EQ(results[0].location, "/moved"); // as it came, not resolved EXPECT_THAT(site.targets(), ElementsAre("/")); } @@ -490,9 +502,23 @@ TEST_F(Probe, DoesNotFollowARedirectOverHttp1) EXPECT_EQ(results[0].status, 302) << notes(results[0]); // As it came, and only HTTP/2 and HTTP/3 have field names in lower case on the wire. EXPECT_THAT(results[0].fields, Contains(Pair("Location", "/moved"))); + EXPECT_EQ(results[0].location, "/moved"); EXPECT_THAT(site.targets(), ElementsAre("/")); } +TEST_F(Probe, HasADestinationForARedirectOnly) +{ + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.status = 201, .location = "/created"}); + + probe(config()); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].status, 201) << notes(results[0]); + EXPECT_THAT(results[0].fields, Contains(Pair("location", "/created"))); + EXPECT_THAT(results[0].location, IsEmpty()); +} + TEST_F(Probe, FallsBackToTheNextRecordWhenAConnectionFails) { // @@ -677,7 +703,7 @@ TEST_F(Probe, TriesIPv6First) EXPECT_EQ(results[0].address, "127.0.0.1"); // the last one tried } -corosio::endpoint at(const char* address) { return {make_address(address), 443}; } +static corosio::endpoint at(const char* address) { return {make_address(address), 443}; } TEST(PreferIpv6, MovesIPv6ToTheFrontAndKeepsTheOrderWithinEachFamily) { @@ -945,16 +971,16 @@ TEST_F(Probe, ReportsAFailedLookup) } // ================================================================================================= -// --h3 +// --h3, --h2 and --h1 // ================================================================================================= -TEST_F(Probe, H3OnlyRequestsAHostWithARecordThatOffersIt) +TEST_F(Probe, H3RequestsAHostWithARecordThatOffersIt) { site.dns().add_https("localhost", 1, ".", {alpn_h3, port_param(site.port())}); site.page("/", {.body = "hello"}); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); @@ -963,61 +989,62 @@ TEST_F(Probe, H3OnlyRequestsAHostWithARecordThatOffersIt) EXPECT_THAT(site.targets(), ElementsAre("/")); } -TEST_F(Probe, H3OnlyLeavesOutAHostWithoutARecord) +TEST_F(Probe, H3LeavesOutAHostWithoutARecord) { site.dns().add_address("localhost", "127.0.0.1"); site.page("/", {.body = "hello"}); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); - EXPECT_EQ(results[0].outcome, Result::Outcome::no_h3); + EXPECT_EQ(results[0].outcome, Result::Outcome::left_out); EXPECT_THAT(results[0].address, IsEmpty()); EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyLeavesOutAHostWhoseRecordsDoNotOfferIt) +TEST_F(Probe, H3LeavesOutAHostWhoseRecordsDoNotOfferIt) { site.dns().add_https("localhost", 1, ".", h2_at(site.port())); site.page("/", {.body = "hello"}); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); - EXPECT_EQ(results[0].outcome, Result::Outcome::no_h3); + EXPECT_EQ(results[0].outcome, Result::Outcome::left_out); EXPECT_EQ(results[0].records.size(), 1u) << "for --all to say why"; EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyStillFallsBackToTcpWhenAConnectionFails) +TEST_F(Probe, H3DoesNotFallBackToTcp) { auto silent = silent_udp_socket(); site.dns().add_https("localhost", 1, ".", h3_at(silent.local_endpoint().port())); - site.dns().add_https("localhost", 2, ".", h2_at(site.port())); + site.dns().add_https("localhost", 2, ".", {alpn_h2, port_param(site.port())}); site.page("/", {.body = "hello"}); auto configuration = config(); configuration.timeout = 200ms; - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); - EXPECT_EQ(results[0].status, 200) << notes(results[0]); - EXPECT_EQ(results[0].route, std::format("h2 localhost:{}", site.port())); - EXPECT_THAT(site.targets(), ElementsAre("/")); + EXPECT_EQ(results[0].outcome, Result::Outcome::failed); + EXPECT_THAT(results[0].error, HasSubstr("timed out")); + EXPECT_THAT(notes(results[0]), HasSubstr("offers no h3")); + EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyStillSaysThatARecordLeadsElsewhere) +TEST_F(Probe, H3StillSaysThatARecordLeadsElsewhere) { site.dns().add_https("localhost", 1, "elsewhere.example.test", h3_at(site.port())); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); @@ -1025,22 +1052,22 @@ TEST_F(Probe, H3OnlyStillSaysThatARecordLeadsElsewhere) EXPECT_THAT(site.requests(), IsEmpty()); } -TEST_F(Probe, H3OnlyStillSaysThatANameDoesNotExist) +TEST_F(Probe, H3StillSaysThatANameDoesNotExist) { auto configuration = config({"nowhere.example.test"}); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); EXPECT_EQ(results[0].outcome, Result::Outcome::not_found); } -TEST_F(Probe, H3OnlyDoesNotTakeAFailedLookupForNoHttp3) +TEST_F(Probe, H3DoesNotTakeAFailedLookupForNoHttp3) { site.dns().set_rcode(ARES_RCODE_SERVFAIL); auto configuration = config(); - configuration.h3_only = true; + configuration.protocols = {.h3 = true}; probe(std::move(configuration)); ASSERT_EQ(results.size(), 1u); @@ -1048,6 +1075,186 @@ TEST_F(Probe, H3OnlyDoesNotTakeAFailedLookupForNoHttp3) EXPECT_THAT(site.requests(), IsEmpty()); } +TEST_F(Probe, H2SpeaksNothingElseThatTheRecordOffers) +{ + const fake_dns_server::svc_param alpn_h3_h2{.key = ARES_SVCB_PARAM_ALPN, + .value = {2, 'h', '3', 2, 'h', '2'}}; + site.dns().add_https("localhost", 1, ".", {alpn_h3_h2, port_param(site.port())}); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h2 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h2 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +TEST_F(Probe, H1SpeaksHttp1WhereTheRecordOffersHttp2AsWell) +{ + site.dns().add_https("localhost", 1, ".", {alpn_h2, port_param(site.port())}); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h1 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +TEST_F(Probe, H1LeavesOutAHostWhoseRecordsOfferOnlyHttp2) +{ + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::left_out); + EXPECT_THAT(notes(results[0]), HasSubstr("offers no h1")); + EXPECT_THAT(results[0].address, IsEmpty()); + EXPECT_THAT(site.requests(), IsEmpty()); +} + +TEST_F(Probe, H1GoesOverTcpToTheDefaultPortWithoutARecord) +{ + site.dns().add_address("localhost", "127.0.0.1"); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.protocols = {.h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h1 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +TEST_F(Probe, H3AndH2FallBackToTcpWhenAConnectionFails) +{ + auto silent = silent_udp_socket(); + + site.dns().add_https("localhost", 1, ".", h3_at(silent.local_endpoint().port())); + site.dns().add_https("localhost", 2, ".", h2_at(site.port())); + site.page("/", {.body = "hello"}); + + auto configuration = config(); + configuration.timeout = 200ms; + configuration.protocols = {.h3 = true, .h2 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].status, 200) << notes(results[0]); + EXPECT_EQ(results[0].route, std::format("h2 localhost:{}", site.port())); + EXPECT_THAT(site.targets(), ElementsAre("/")); +} + +namespace +{ + +// +// A server over TLS that speaks HTTP/1.1 and nothing else, by ALPN, where the site's server takes +// HTTP/2 whenever it is offered. It answers a request with a 204 and keeps what it was sent. +// +class Http1Server : public Probe +{ +protected: + void SetUp() override + { + ASSERT_FALSE(tls.use_certificate_chain_file(site.ca_file().string())); + ASSERT_FALSE( + tls.use_private_key_file(site.key_file().string(), corosio::tls_file_format::pem)); + ASSERT_FALSE(tls.set_alpn({"http/1.1"})); + capy::run_async(context.get_executor())(serve()); + + port = acceptor.local_endpoint().port(); // which is 0 once it is closed + site.dns().add_https("localhost", 1, ".", {alpn_h2, port_param(port)}); + } + + void stop_sites() override + { + site.stop(); + acceptor.close(); + } + + capy::task<> serve() + { + for (;;) + { + corosio::tcp_socket socket(context); + if (auto [ec] = co_await acceptor.accept(socket); ec) + co_return; + ++connections; + + corosio::openssl_stream stream(std::move(socket), tls); + if (auto [ec] = co_await stream.handshake(corosio::tls_role::server); ec) + continue; + + std::string request; + std::array buffer; + while (!request.contains("\r\n\r\n")) + { + auto [ec, n] = co_await stream.read_some(capy::make_buffer(buffer)); + if (ec || n == 0) + break; + request.append(buffer.data(), n); + } + received += request; + + if (request.contains("\r\n\r\n")) + { + constexpr std::string_view response = + "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"; + std::ignore = co_await capy::write(stream, capy::make_buffer(response)); + } + std::ignore = co_await stream.shutdown(); + } + } + + corosio::tcp_acceptor acceptor = silent_tcp_acceptor(); + std::uint16_t port = 0; + corosio::tls_context tls; + std::string received; ///< all that came in over all connections, once decrypted + int connections = 0; +}; + +} // namespace + +TEST_F(Http1Server, H2DoesNotSendTheRequestToAServerThatChoosesHttp1) +{ + auto configuration = config(); + configuration.protocols = {.h2 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::failed); + EXPECT_THAT(results[0].error, HasSubstr("chose h1")); + EXPECT_GE(connections, 1); + EXPECT_THAT(received, IsEmpty()); +} + +TEST_F(Http1Server, H2AndH1LetTheServerChooseHttp1) +{ + auto configuration = config(); + configuration.protocols = {.h2 = true, .h1 = true}; + probe(std::move(configuration)); + + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results[0].outcome, Result::Outcome::answered) << notes(results[0]); + EXPECT_EQ(results[0].status, 204); + EXPECT_EQ(results[0].route, std::format("h1 localhost:{}", port)); + EXPECT_THAT(received, testing::StartsWith("GET / HTTP/1.1\r\n")); + EXPECT_EQ(connections, 1); +} + // ================================================================================================= // --dns-only // ================================================================================================= @@ -1146,7 +1353,7 @@ TEST_F(Probe, DnsOnlyStillSaysThatThereIsNoAddress) EXPECT_THAT(notes(results[0]), HasSubstr("address lookup")); } -TEST_F(Probe, DnsOnlyGoesWithH3Only) +TEST_F(Probe, DnsOnlyGoesWithH3) { site.dns().add_https("offers.example.test", 1, ".", h3_at(site.port())); site.dns().add_address("offers.example.test", "127.0.0.1"); @@ -1155,13 +1362,13 @@ TEST_F(Probe, DnsOnlyGoesWithH3Only) auto configuration = config({"offers.example.test", "lacks.example.test"}); configuration.dns_only = true; - configuration.h3_only = true; + configuration.protocols = {.h3 = 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_EQ(results[1].outcome, Result::Outcome::left_out); EXPECT_THAT(site.requests(), IsEmpty()); } @@ -1195,6 +1402,30 @@ TEST_F(Probe, EachAddressSendsOneRequestToEachOfThem) EXPECT_EQ(site.requests().size(), results[0].replies.size()); } +// +// The server takes its time with each of them, so that one after the other, no request would arrive +// before the response to the one before. +// +TEST_F(Probe, EachAddressAsksAllOfThemAtTheSameTime) +{ + constexpr auto delay = 400ms; + site.dns().add_https("localhost", 1, ".", h2_at(site.port())); + site.page("/", {.body = "hello", .delay = delay}); + + 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]); + if (results[0].replies.size() < 2) + GTEST_SKIP() << "localhost has only one address here"; + ASSERT_EQ(site.requests().size(), results[0].replies.size()); + const auto [first, last] = + std::ranges::minmax(site.requests() | std::views::transform(&Site::Request::time)); + EXPECT_LT(last - first, delay); +} + TEST_F(Probe, EachAddressGoesOverHttp3) { site.dns().add_https("localhost", 1, ".", h3_at(site.port())); @@ -1241,7 +1472,10 @@ TEST_F(Probe, EachAddressReportsARedirectAndDoesNotFollowIt) 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_EQ(reply.location, "/elsewhere"); + } EXPECT_THAT(site.targets(), testing::Each("/")); EXPECT_EQ(site.requests().size(), results[0].replies.size()); } diff --git a/test/routes_test.cpp b/test/routes_test.cpp index 068d9ea..9b6468f 100644 --- a/test/routes_test.cpp +++ b/test/routes_test.cpp @@ -29,6 +29,7 @@ ServiceRecords found(std::vector records, std::vector routes(const Plan& plan) { std::vector result; + result.reserve(plan.routes.size()); for (const auto& route : plan.routes) result.push_back(route.to_string()); return result; @@ -121,13 +122,13 @@ TEST(Plan, GoesOverTcpToTheDefaultPortWithoutARecord) { EXPECT_THAT(routes(plan_routes("example.com", found({}))), ElementsAre("h2 example.com:443")); EXPECT_THAT(plan_routes("example.com", found({})).rejected, IsEmpty()) << "nothing odd about it"; - EXPECT_THAT(routes(plan_routes("example.com", found({}), 8443)), + EXPECT_THAT(routes(plan_routes("example.com", found({}), {}, 8443)), ElementsAre("h2 example.com:8443")); } TEST(Plan, RecordsThatDoNotSayGoToTheDefaultPort) { - EXPECT_THAT(routes(plan_routes("example.com", found({record(1, ".", {"alpn=h2"})}), 8443)), + EXPECT_THAT(routes(plan_routes("example.com", found({record(1, ".", {"alpn=h2"})}), {}, 8443)), ElementsAre("h2 example.com:8443")); } @@ -289,36 +290,163 @@ TEST(Plan, TreatsARecordWithAnInvalidPortAsNoRecord) EXPECT_THAT(routes(plan), ElementsAre("h2 example.com:443")); } -TEST(HasH3Record, IsTrueForARecordThatOffersIt) +TEST(Plan, PassesOnWhatWentWrongInTheLookup) { - EXPECT_TRUE(has_h3_record(found({record(1, ".", {"alpn=h3"})}))); - EXPECT_TRUE(has_h3_record(found({record(1, ".", {"alpn=h2,h3"})}))); - EXPECT_TRUE(has_h3_record(found({record(1, ".", {"alpn=h2"}), record(2, ".", {"alpn=h3"})}))); + auto records = found({}); + records.notes.emplace_back("HTTPS lookup failed"); + + EXPECT_THAT(plan_routes("example.com", records).rejected.front(), "HTTPS lookup failed"); } -TEST(HasH3Record, DoesNotCareWhereTheRecordPoints) +// ------------------------------------------------------------------------------------------------- +// --h3, --h2 and --h1 +// ------------------------------------------------------------------------------------------------- + +namespace { - EXPECT_TRUE(has_h3_record(found({record(1, "other.example", {"alpn=h3"})}))); +const Protocols h3{.h3 = true}; +const Protocols h2{.h2 = true}; +const Protocols h1{.h1 = true}; +} // namespace + +TEST(Protocols, SpeaksThoseGivenOrAllWithoutAny) +{ + EXPECT_TRUE(Protocols{}.speaks(anyhttp::Protocol::h3)); + EXPECT_TRUE(Protocols{}.speaks(anyhttp::Protocol::h1)); + EXPECT_TRUE(h2.speaks(anyhttp::Protocol::h2)); + EXPECT_FALSE(h2.speaks(anyhttp::Protocol::h1)); + EXPECT_FALSE(h3.speaks_tcp()); + EXPECT_TRUE((Protocols{.h3 = true, .h1 = true}).speaks_tcp()); } -TEST(HasH3Record, IsFalseForAnythingElse) +TEST(Protocols, SaysWhichTheyAre) { - EXPECT_FALSE(has_h3_record(found({}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".")}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h2"})}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h3-29,h2"})}))); + EXPECT_EQ(h2.to_string(), "h2"); + EXPECT_EQ((Protocols{.h3 = true, .h1 = true}).to_string(), "h3 or h1"); + EXPECT_EQ(Protocols{}.to_string(), "h3 or h2 or h1"); } -TEST(HasH3Record, IgnoresARecordThatHasToBeIgnored) +TEST(Plan, SpeaksOnlyTheProtocolsGiven) { - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h3", "mandatory=ech", "ech=dead"})}))); - EXPECT_FALSE(has_h3_record(found({record(1, ".", {"alpn=h3", "port=none"})}))); + const auto records = found({record(1, ".", {"alpn=h3,h2"})}); + + EXPECT_THAT(routes(plan_routes("example.com", records, h3)), ElementsAre("h3 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", records, h2)), ElementsAre("h2 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", records, h1)), ElementsAre("h1 example.com:443")) + << "the default set"; + EXPECT_THAT(routes(plan_routes("example.com", records, {.h3 = true, .h1 = true})), + ElementsAre("h3 example.com:443", "h1 example.com:443")); } -TEST(Plan, PassesOnWhatWentWrongInTheLookup) +TEST(Plan, LeavesTheChoiceOverTcpToTheServerWithBothOfThem) +{ + const auto plan = + plan_routes("example.com", found({record(1, ".", {"alpn=h2"})}), {.h2 = true, .h1 = true}); + + EXPECT_THAT(routes(plan), ElementsAre("h2 example.com:443")); +} + +TEST(Plan, GoesOverTcpWithoutARecordWithTheProtocolGiven) +{ + EXPECT_THAT(routes(plan_routes("example.com", found({}), h2)), + ElementsAre("h2 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", found({}), h1)), + ElementsAre("h1 example.com:443")); + EXPECT_THAT(routes(plan_routes("example.com", found({}), {.h3 = true, .h1 = true})), + ElementsAre("h1 example.com:443")); +} + +TEST(Plan, LeavesOutAHostWithoutARecordForH3Alone) +{ + const auto plan = plan_routes("example.com", found({}), h3); + + EXPECT_THAT(plan.routes, IsEmpty()); + EXPECT_TRUE(plan.left_out); + EXPECT_FALSE(plan.out_of_reach); + EXPECT_THAT(plan.rejected, ElementsAre("no usable HTTPS record offers h3")); +} + +TEST(Plan, LeavesOutAHostWhoseRecordsOfferNoneOfThem) +{ + const auto plan = plan_routes("example.com", + found({record(1, ".", {"alpn=h3", "no-default-alpn"}), + record(2, ".", {"alpn=h3-29,h2", "no-default-alpn"})}), + h1); + + EXPECT_THAT(plan.routes, IsEmpty()); + EXPECT_TRUE(plan.left_out); + EXPECT_FALSE(plan.out_of_reach); + EXPECT_THAT(plan.rejected, ElementsAre(HasSubstr("offers no h1"), HasSubstr("offers no h1"))); +} + +TEST(Plan, SaysWhichRecordsOfferNoneOfThem) +{ + const auto plan = plan_routes( + "example.com", found({record(1, ".", {"alpn=h2"}), record(2, ".", {"alpn=h3", "port=8443"})}), + h3); + + EXPECT_THAT(routes(plan), ElementsAre("h3 example.com:8443")); + EXPECT_THAT(plan.rejected, ElementsAre("HTTPS 1 . alpn=h2: offers no h3")); +} + +TEST(Plan, LeavesOutOnlyByWhatTheRecordsOfferWhereverTheyPoint) +{ + // + // A record that offers the protocol, on a target that can't be used, is out of reach as ever. + // + const auto offered = + plan_routes("example.com", found({record(1, "other.example", {"alpn=h3"})}), h3); + EXPECT_TRUE(offered.out_of_reach); + EXPECT_FALSE(offered.left_out); + + const auto not_offered = plan_routes( + "example.com", found({record(1, "other.example", {"alpn=h2", "no-default-alpn"})}), h3); + EXPECT_TRUE(not_offered.left_out); + EXPECT_FALSE(not_offered.out_of_reach); +} + +TEST(Plan, TakesARecordThatHasToBeIgnoredForNoRecordWithTheProtocolsGiven) +{ + for (const auto& params : std::vector>{ + {"alpn=h3", "mandatory=ech", "ech=dead"}, {"alpn=h3", "port=none"}}) + { + const auto records = found({record(1, ".", params)}); + + EXPECT_TRUE(plan_routes("example.com", records, h3).left_out); + EXPECT_THAT(routes(plan_routes("example.com", records, h2)), + ElementsAre("h2 example.com:443")); + } +} + +TEST(Plan, LeavesOutAnUnavailableServiceWithoutTcp) { auto records = found({}); - records.notes.push_back("HTTPS lookup failed"); + records.unavailable = true; - EXPECT_THAT(plan_routes("example.com", records).rejected.front(), "HTTPS lookup failed"); + EXPECT_TRUE(plan_routes("example.com", records, h3).left_out); + EXPECT_TRUE(plan_routes("example.com", records, h1).out_of_reach); + EXPECT_FALSE(plan_routes("example.com", records, h1).left_out); +} + +TEST(Plan, LeavesOutTheTargetOfAnAliasWithoutTcp) +{ + const auto records = found({}, "other.example", true); + + EXPECT_TRUE(plan_routes("example.com", records, h3).left_out); + EXPECT_TRUE(plan_routes("example.com", records, h2).out_of_reach); +} + +TEST(Plan, LeavesOutNeitherANameThatDoesNotExistNorOneThatWasNotLookedUp) +{ + auto not_found = found({}); + not_found.not_found = true; + auto lookup_failed = found({}); + lookup_failed.lookup_failed = true; + + for (const auto& records : {not_found, lookup_failed}) + { + const auto plan = plan_routes("example.com", records, h3); + EXPECT_FALSE(plan.left_out); + EXPECT_FALSE(plan.out_of_reach); + } } diff --git a/test/site.hpp b/test/site.hpp index c126cc3..b6b8dd4 100644 --- a/test/site.hpp +++ b/test/site.hpp @@ -63,6 +63,7 @@ class Site std::uint16_t port() const { return port_; } const std::filesystem::path& ca_file() const { return certificate_file_; } + const std::filesystem::path& key_file() const { return key_file_; } ///< of the certificate /// The zone. Good until stop(), as opposed to what was asked of it, see dns_queries(). fake_dns_server& dns() { return *dns_; }