Clean up addr_map hostname support

Follow-up to 49b921b.

Move the duplicated addr_map lookup into detail::apply_addr_map, shared by
ClientImpl::create_client_socket and WebSocketClient::connect.

Add a WebSocketClient test for a hostname mapped value, so that path has
the same coverage as the Client one. Guard its teardown with scope_exit:
a failing ASSERT_TRUE returns from the test body, and destroying a still
joinable std::thread calls std::terminate, taking the whole binary down.

Document set_hostname_addr_map in README. It had no entry at all.
This commit is contained in:
yhirose committed 2026-08-01 14:51:35 -04:00
1 parent 49b921b52d
commit ae8356d86e
3 files changed
+101 -27

No files matched your search

+28
View File
@@ -1289,6 +1289,34 @@ res->status; // 200
cli.set_interface("eth0"); // Interface name, IP address or host name 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 ### Automatic Path Encoding
The client automatically encodes special characters in URL paths by default: The client automatically encodes special characters in URL paths by default:
+32 -27
View File
@@ -9105,6 +9105,32 @@ inline bool is_ip_address(const std::string &host) {
inet_pton(AF_INET6, host.c_str(), &addr6) == 1; 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<std::string, std::string> &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 } // namespace detail
/* /*
@@ -12993,20 +13019,10 @@ inline socket_t ClientImpl::create_client_socket(Error &error) const {
write_timeout_sec_, write_timeout_usec_, interface_, error); write_timeout_sec_, write_timeout_usec_, interface_, error);
} }
// Check is custom IP or hostname specified for host_. // Check is custom IP or hostname specified for host_
// An IP literal goes to the ip argument, which keeps create_socket's std::string connect_host;
// 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_;
std::string ip; std::string ip;
auto it = addr_map_.find(host_); detail::apply_addr_map(addr_map_, host_, connect_host, ip);
if (it != addr_map_.end() && !it->second.empty()) {
if (detail::is_ip_address(it->second)) {
ip = it->second;
} else {
connect_host = it->second;
}
}
return detail::create_client_socket( return detail::create_client_socket(
connect_host, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_, connect_host, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_,
@@ -20871,21 +20887,10 @@ inline bool WebSocketClient::connect() {
if (!is_valid_) { return false; } if (!is_valid_) { return false; }
shutdown_and_close(); shutdown_and_close();
// Check is custom IP or hostname specified for host_. // Check is custom IP or hostname specified for host_
// host_ stays the identity used for the Host header and for SNI, while the std::string connect_host;
// 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_;
std::string ip; std::string ip;
auto it = addr_map_.find(host_); detail::apply_addr_map(addr_map_, host_, connect_host, ip);
if (it != addr_map_.end() && !it->second.empty()) {
if (detail::is_ip_address(it->second)) {
ip = it->second;
} else {
connect_host = it->second;
}
}
Error error; Error error;
sock_ = detail::create_client_socket( sock_ = detail::create_client_socket(
+41
View File
@@ -19395,6 +19395,47 @@ TEST(WebSocketTest, SpecifyServerIPAddress_RealHostname) {
t.join(); 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 { class WebSocketIntegrationTest : public ::testing::Test {
protected: protected:
void SetUp() override { void SetUp() override {