diff --git a/README.md b/README.md index 199505b..814c5fd 100644 --- a/README.md +++ b/README.md @@ -1289,6 +1289,34 @@ res->status; // 200 cli.set_interface("eth0"); // Interface name, IP address or host name ``` +### Override the connection target for a hostname + +`set_hostname_addr_map` redirects where the socket connects, without changing +the identity of the request. The hostname the client was constructed with keeps +supplying the `Host` header, the SNI, and the name that the server certificate +is verified against, so this is a connection-level override only, not a way to +talk to a different origin. + +```cpp +httplib::Client cli("https://example.com"); + +// Connect to this IP address instead of resolving "example.com" +cli.set_hostname_addr_map({{"example.com", "192.168.1.10"}}); +``` + +A mapped value may be an IP literal or another hostname. An IP literal is used +as-is; anything else is resolved as a name, so a host that is only reachable +under a different name works too: + +```cpp +cli.set_hostname_addr_map({{"example.com", "internal.example.lan"}}); +``` + +An empty value is ignored, leaving the original hostname as the connection +target. + +The same method is available on `httplib::ws::WebSocketClient`. + ### Automatic Path Encoding The client automatically encodes special characters in URL paths by default: diff --git a/httplib.h b/httplib.h index dbbd20c..352e09c 100644 --- a/httplib.h +++ b/httplib.h @@ -9105,6 +9105,32 @@ inline bool is_ip_address(const std::string &host) { inet_pton(AF_INET6, host.c_str(), &addr6) == 1; } +// Resolve where a client should connect for `host`, honoring a user-supplied +// hostname-to-address map. `host` itself is never rewritten, so it keeps +// supplying the Host header and SNI; only the connection target changes. +// +// A mapped IP literal goes to `ip`, which keeps create_socket's AI_NUMERICHOST +// path. Anything else goes to `connect_host`, which create_socket resolves as +// a name, or uses as the socket path when the address family is AF_UNIX. An +// absent or empty mapping leaves `host` as the connection target; without the +// empty check the value would reach getaddrinfo as a null node and silently +// resolve to loopback. +inline void apply_addr_map(const std::map &addr_map, + const std::string &host, std::string &connect_host, + std::string &ip) { + connect_host = host; + ip.clear(); + + auto it = addr_map.find(host); + if (it == addr_map.end() || it->second.empty()) { return; } + + if (is_ip_address(it->second)) { + ip = it->second; + } else { + connect_host = it->second; + } +} + } // namespace detail /* @@ -12993,20 +13019,10 @@ inline socket_t ClientImpl::create_client_socket(Error &error) const { write_timeout_sec_, write_timeout_usec_, interface_, error); } - // Check is custom IP or hostname specified for host_. - // An IP literal goes to the ip argument, which keeps create_socket's - // AI_NUMERICHOST path; a hostname goes to the host argument so that it is - // resolved. Either way host_ still supplies the Host header and SNI. - auto connect_host = host_; + // Check is custom IP or hostname specified for host_ + std::string connect_host; std::string ip; - auto it = addr_map_.find(host_); - if (it != addr_map_.end() && !it->second.empty()) { - if (detail::is_ip_address(it->second)) { - ip = it->second; - } else { - connect_host = it->second; - } - } + detail::apply_addr_map(addr_map_, host_, connect_host, ip); return detail::create_client_socket( connect_host, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_, @@ -20871,21 +20887,10 @@ inline bool WebSocketClient::connect() { if (!is_valid_) { return false; } shutdown_and_close(); - // Check is custom IP or hostname specified for host_. - // host_ stays the identity used for the Host header and for SNI, while the - // mapped value only redirects where the socket connects. An IP literal goes - // to the ip argument, which keeps create_socket's AI_NUMERICHOST path; a - // hostname goes to the host argument so that it is resolved. - auto connect_host = host_; + // Check is custom IP or hostname specified for host_ + std::string connect_host; std::string ip; - auto it = addr_map_.find(host_); - if (it != addr_map_.end() && !it->second.empty()) { - if (detail::is_ip_address(it->second)) { - ip = it->second; - } else { - connect_host = it->second; - } - } + detail::apply_addr_map(addr_map_, host_, connect_host, ip); Error error; sock_ = detail::create_client_socket( diff --git a/test/test.cc b/test/test.cc index 972aef6..4af7f76 100644 --- a/test/test.cc +++ b/test/test.cc @@ -19395,6 +19395,47 @@ TEST(WebSocketTest, SpecifyServerIPAddress_RealHostname) { t.join(); } +TEST(WebSocketTest, SpecifyServerIPAddress_HostnameAsAddrMapValue) { + // A mapped value that is not an IP literal must be resolved. HOST resolves + // from the hosts file, so this test needs no external DNS. "target.invalid" + // (RFC 6761) is only a map key and the Host header value. + auto host = "target.invalid"; + + Server svr; + std::string received_host; + svr.WebSocket("/ws", [&](const Request &req, ws::WebSocket &ws) { + received_host = req.get_header_value("Host"); + std::string msg; + while (ws.read(msg)) {} + }); + + auto port = svr.bind_to_any_port(HOST); + std::thread t([&]() { svr.listen_after_bind(); }); + + // ASSERT_* below returns from the test body, which would leave t joinable + // and make ~thread call std::terminate. + auto se = detail::scope_exit([&] { + svr.stop(); + if (t.joinable()) { t.join(); } + }); + + svr.wait_until_ready(); + + ws::WebSocketClient client("ws://" + std::string(host) + ":" + + std::to_string(port) + "/ws"); + client.set_hostname_addr_map({{host, HOST}}); + + ASSERT_TRUE(client.connect()); + EXPECT_TRUE(client.is_open()); + client.close(); + + svr.stop(); + t.join(); + + // The mapping only redirects the connection; the identity stays host_. + EXPECT_EQ(std::string(host) + ":" + std::to_string(port), received_host); +} + class WebSocketIntegrationTest : public ::testing::Test { protected: void SetUp() override {