diff --git a/src/client.cpp b/src/client.cpp index 948097736..b12d58290 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -68,6 +68,8 @@ constexpr timeval beaconCleanInterval{180, 0}; // special interval to attempt to reconnect to disconnected name servers constexpr timeval tcpNSCheckInterval{10, 0}; +constexpr timeval dnsRecheckInterval{10, 0}; + // searchSequenceID in CMD_SEARCH is redundant. // So we use a static value and instead rely on IDs for individual PVs constexpr uint32_t search_seq{0x66696e64}; // "find" @@ -217,13 +219,18 @@ void Channel::disconnect(const std::shared_ptr& self) name.c_str()); } else if(context->state==ContextImpl::Running) { // reconnect to specific server + if(!forcedServerHostname.empty()) { + forcedServer.setAddress(forcedServerHostname.c_str(), forcedServer.port()); + log_info_printf(io, "Forced server re-resolved for '%s': %s\n", + name.c_str(), forcedServer.tostring().c_str()); + } + conn = Connection::build(context, forcedServer, true); conn->pending[cid] = self; state = Connecting; conn->createChannels(); - } } @@ -382,6 +389,9 @@ std::shared_ptr Channel::build(const std::shared_ptr& cont } else { // bypass search and connect so a specific server chan->forcedServer = forceServer; + if(isHostname(server)) { + chan->forcedServerHostname = server; + } chan->conn = Connection::build(context, forceServer); chan->conn->pending[chan->cid] = chan; @@ -564,6 +574,8 @@ ContextImpl::ContextImpl(const Config& conf, const evbase& tcp_loop) event_new(tcp_loop.base, -1, EV_TIMEOUT|EV_PERSIST, &ContextImpl::cacheCleanS, this)) ,nsChecker(__FILE__, __LINE__, event_new(tcp_loop.base, -1, EV_TIMEOUT|EV_PERSIST, &ContextImpl::onNSCheckS, this)) + ,dnsRecheckTimer(__FILE__, __LINE__, + event_new(tcp_loop.base, -1, EV_TIMEOUT|EV_PERSIST, &ContextImpl::onDNSRecheckS, this)) { searchBuckets.resize(nBuckets); @@ -614,8 +626,15 @@ ContextImpl::ContextImpl(const Config& conf, const evbase& tcp_loop) if(isucast && ep.addr.family()==AF_INET && bcasts.find(ep.addr)!=bcasts.end()) isucast = false; - log_info_printf(io, "Searching to %s%s\n", std::string(SB()<second; + + log_info_printf(io, "Searching to %s%s%s\n", std::string(SB()<second; + + log_warn_printf(io, "TRACE nameServers loop: addr='%s' hostnameMapSize=%zu found=%d nsHostname='%s'\n", + addr.c_str(), effective.nameServerHostnames.size(), (int)(hit != effective.nameServerHostnames.end()), nsHostname.c_str()); + + log_info_printf(io, "Searching to TCP %s%s\n", saddr.tostring().c_str(), + (nsHostname.empty()?"":(std::string(" hostname=")+nsHostname).c_str())); + nameServers.push_back({saddr, nullptr, std::move(nsHostname)}); } if(searchDest.empty() && nameServers.empty()) @@ -673,20 +702,27 @@ ContextImpl::~ContextImpl() {} void ContextImpl::startNS() { - if(nameServers.empty()) // vector size const after ctor, contents remain mutable + bool hasHostnames = std::any_of(searchDest.begin(), searchDest.end(), + [](const SearchDest& sd){ return !sd.hostname.empty(); }); + + if(nameServers.empty() && !hasHostnames) return; - tcp_loop.call([this]() { - // start connections to name servers + tcp_loop.call([this, hasHostnames]() { for(auto& ns : nameServers) { - const auto& serv = ns.first; - ns.second = Connection::build(shared_from_this(), serv); - ns.second->nameserver = true; - log_debug_printf(io, "Connecting to nameserver %s\n", ns.second->peerName.c_str()); + ns.conn = Connection::build(shared_from_this(), ns.addr); + ns.conn->nameserver = true; + log_debug_printf(io, "Connecting to nameserver %s%s%s\n", ns.conn->peerName.c_str(), + (ns.hostname.empty()?"":" hostname="), ns.hostname.c_str()); } - if(event_add(nsChecker.get(), &tcpNSCheckInterval)) - log_err_printf(setup, "Error enabling TCP search reconnect timer\n%s", ""); + if(!nameServers.empty()) { + if(event_add(nsChecker.get(), &tcpNSCheckInterval)) + log_err_printf(setup, "Error enabling TCP search reconnect timer\n%s", ""); + } + + if(event_add(dnsRecheckTimer.get(), &dnsRecheckInterval)) + log_err_printf(setup, "Error enabling DNS recheck timer\n%s", ""); }); } @@ -705,6 +741,7 @@ void ContextImpl::close() (void)event_del(searchRx6.get()); (void)event_del(beaconCleaner.get()); (void)event_del(cacheCleaner.get()); + (void)event_del(dnsRecheckTimer.get()); auto conns(std::move(connByAddr)); // explicitly break ref. loop of channel cache @@ -1224,7 +1261,7 @@ void ContextImpl::tickSearch(SearchKind kind, bool poked) pport[0] = pport[1] = 0; for(auto& pair : nameServers) { - auto& serv = pair.second; + auto& serv = pair.conn; if(!serv->ready || !serv->connection()) continue; @@ -1323,12 +1360,18 @@ void ContextImpl::tickBeaconCleanS(evutil_socket_t fd, short evt, void *raw) void ContextImpl::onNSCheck() { for(auto& ns : nameServers) { - if(ns.second && ns.second->state != ConnBase::Disconnected) // hold-off, connecting, or connected + if(!ns.hostname.empty()) + continue; // hostname entries owned by onDNSRecheck + + if(ns.conn && ns.conn->state != ConnBase::Disconnected) continue; - ns.second = Connection::build(shared_from_this(), ns.first); - ns.second->nameserver = true; - log_debug_printf(io, "Reconnecting nameserver %s\n", ns.second->peerName.c_str()); + // drop old conn first so its dtor's connByAddr.erase(peerAddr) runs + // before build() inserts the fresh entry (same addr -> would erase it) + ns.conn.reset(); + ns.conn = Connection::build(shared_from_this(), ns.addr); + ns.conn->nameserver = true; + log_debug_printf(io, "Reconnecting nameserver %s\n", ns.conn->peerName.c_str()); } } @@ -1341,6 +1384,75 @@ void ContextImpl::onNSCheckS(evutil_socket_t fd, short evt, void *raw) } } +void ContextImpl::onDNSRecheck() +{ + for(auto& sd : searchDest) { + if(sd.hostname.empty()) + continue; + + SockAddr resolved; + try { + resolved.setAddress(sd.hostname.c_str(), sd.dest.addr.port()); + } catch(std::exception& e) { + log_warn_printf(io, "DNS resolution failed for search dest '%s': %s\n", + sd.hostname.c_str(), e.what()); + continue; + } + + if(resolved != sd.dest.addr) { + log_info_printf(io, "Search dest %s re-resolved: %s -> %s\n", + sd.hostname.c_str(), + sd.dest.addr.tostring().c_str(), + resolved.tostring().c_str()); + sd.dest.addr = resolved; + } + } + + for(auto& ns : nameServers) { + if(ns.hostname.empty()) + continue; + + SockAddr resolved; + try { + resolved.setAddress(ns.hostname.c_str(), ns.addr.port()); + } catch(std::exception& e) { + log_warn_printf(io, "DNS resolution failed for nameserver '%s': %s\n", + ns.hostname.c_str(), e.what()); + continue; + } + + bool ipChanged = (resolved != ns.addr); + bool connDown = (!ns.conn || ns.conn->state == ConnBase::Disconnected); + + if(ipChanged) { + log_info_printf(io, "Nameserver %s re-resolved: %s -> %s\n", + ns.hostname.c_str(), ns.addr.tostring().c_str(), + resolved.tostring().c_str()); + ns.addr = resolved; + } + + if(ipChanged || connDown) { + // drop old conn first so its dtor's connByAddr.erase(peerAddr) runs + // before build() inserts the fresh entry + ns.conn.reset(); + ns.conn = Connection::build(shared_from_this(), ns.addr); + ns.conn->nameserver = true; + log_debug_printf(io, "Reconnecting nameserver %s (%s)%s\n", + ns.conn->peerName.c_str(), ns.hostname.c_str(), + ipChanged ? " after DNS change" : ""); + } + } +} + +void ContextImpl::onDNSRecheckS(evutil_socket_t fd, short evt, void *raw) +{ + try { + static_cast(raw)->onDNSRecheck(); + }catch(std::exception& e){ + log_exc_printf(io, "Unhandled error in DNS recheck timer callback: %s\n", e.what()); + } +} + void ContextImpl::cacheClean(const std::string& name, Context::cacheAction action) { auto next(chanByName.begin()), diff --git a/src/clientimpl.h b/src/clientimpl.h index 8745c5cdd..35deeb16d 100644 --- a/src/clientimpl.h +++ b/src/clientimpl.h @@ -198,6 +198,7 @@ struct Channel { // channel created with .server() to bypass normal search process SockAddr forcedServer; + std::string forcedServerHostname; // when state==Searching, number of repetitions size_t nSearch = 0u; @@ -275,10 +276,12 @@ struct ContextImpl : public std::enable_shared_from_this // search destination address and whether to set the unicast flag struct SearchDest { - const SockEndpoint dest; + SockEndpoint dest; const bool isucast; bool lastSuccess = true; - SearchDest(SockEndpoint dest, bool isu) :dest(dest), isucast(isu) {} + std::string hostname; + SearchDest(SockEndpoint dest, bool isu, std::string hostname = {}) + :dest(dest), isucast(isu), hostname(std::move(hostname)) {} }; std::vector searchDest; @@ -299,7 +302,12 @@ struct ContextImpl : public std::enable_shared_from_this std::map> connByAddr; - std::vector>> nameServers; + struct NameServerEntry { + SockAddr addr; + std::shared_ptr conn; + std::string hostname; + }; + std::vector nameServers; evbase tcp_loop; const evevent searchRx4, searchRx6; @@ -315,6 +323,7 @@ struct ContextImpl : public std::enable_shared_from_this const evevent beaconCleaner; const evevent cacheCleaner; const evevent nsChecker; + const evevent dnsRecheckTimer; INST_COUNTER(ClientContextImpl); @@ -345,6 +354,8 @@ struct ContextImpl : public std::enable_shared_from_this static void cacheCleanS(evutil_socket_t fd, short evt, void *raw); void onNSCheck(); static void onNSCheckS(evutil_socket_t fd, short evt, void *raw); + void onDNSRecheck(); + static void onDNSRecheckS(evutil_socket_t fd, short evt, void *raw); }; struct Context::Pvt { diff --git a/src/config.cpp b/src/config.cpp index fc292ae7b..493e1bb17 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -149,7 +149,8 @@ namespace { constexpr double tmoScale = 4.0/3.0; // 40 second idle timeout / 30 configured void split_addr_into(const char* name, std::vector& out, const std::string& inp, - uint16_t defaultPort, bool required=false) + uint16_t defaultPort, bool required=false, + std::map* hostnameMap=nullptr) { size_t pos=0u; @@ -166,7 +167,16 @@ void split_addr_into(const char* name, std::vector& out, const std: SockEndpoint ep(temp); if(ep.addr.port()==0) ep.addr.setPort(defaultPort); - out.push_back(SB()<size()); + } } catch(std::exception& e){ if(required) @@ -572,17 +582,20 @@ void _fromDefs(Config& self, const std::map& defs, boo log_warn_printf(clientsetup, "%s invalid integer : %s", pickone.name.c_str(), e.what()); } } - if(self.tcp_port==0u && !self.nameServers.empty()) { - log_warn_printf(clientsetup, "ignoring EPICS_PVA_SERVER_PORT=%d\n", 0); - self.tcp_port = 5075; - } - if(pickone({"EPICS_PVA_ADDR_LIST"})) { - split_addr_into(pickone.name.c_str(), self.addressList, pickone.val, self.udp_port); + split_addr_into(pickone.name.c_str(), self.addressList, pickone.val, self.udp_port, + false, &self.addressHostnames); } if(pickone({"EPICS_PVA_NAME_SERVERS"})) { - split_addr_into(pickone.name.c_str(), self.nameServers, pickone.val, self.tcp_port); + auto nameServersName(pickone.name); + auto nameServersVal(pickone.val); + if(self.tcp_port==0u) { + log_warn_printf(clientsetup, "ignoring EPICS_PVA_SERVER_PORT=%d\n", 0); + self.tcp_port = 5075; + } + split_addr_into(nameServersName.c_str(), self.nameServers, nameServersVal, self.tcp_port, + false, &self.nameServerHostnames); } if(pickone({"EPICS_PVA_AUTO_ADDR_LIST"})) { diff --git a/src/pvxs/client.h b/src/pvxs/client.h index 91cde741a..227b8d41d 100644 --- a/src/pvxs/client.h +++ b/src/pvxs/client.h @@ -1026,6 +1026,16 @@ struct PVXS_API Config { //! @since 0.2.0 std::vector nameServers; + //! Maps resolved IP string -> original hostname for entries in addressList. + //! Populated automatically when addressList entries are hostnames. + //! @since NEXT + std::map addressHostnames; + + //! Maps resolved IP string -> original hostname for entries in nameServers. + //! Populated automatically when nameServers entries are hostnames. + //! @since NEXT + std::map nameServerHostnames; + //! UDP port to bind. Default is 5076. May be zero, cf. Server::config() to find allocated port. unsigned short udp_port = 5076; //! Default TCP port for name servers diff --git a/src/util.cpp b/src/util.cpp index 76f473098..569c9aed3 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -452,6 +452,14 @@ void SockAddr::setAddress(const char *name, unsigned short defport) * [ipv6] * ipv4:port * ipv4 + * hostname:port + * hostname + * + * "addr" below is first tried as a literal IP (old behavior, no DNS + * involved). Only when that parse fails is it treated as a hostname + * and resolved via DNS (see evutil_inet_pton()/GetAddrInfo fallback + * below) -- so any of the ipv4/ipv6 forms above may have its address + * portion replaced with a hostname. */ // TODO: could optimize to find all of these with a single loop const char *firstc = strchr(name, ':'), @@ -464,14 +472,14 @@ void SockAddr::setAddress(const char *name, unsigned short defport) throw std::runtime_error(SB()<<"IPv6 with mismatched brackets \""<sa.sa_family = AF_INET; @@ -479,14 +487,9 @@ void SockAddr::setAddress(const char *name, unsigned short defport) } else if(firstc && firstc==lastc && !openb) { // no bracket and only one ':' - // ipv4 w/ port - size_t addrlen = firstc-name; - if(addrlen >= sizeof(scratch)) - throw std::runtime_error(SB()<<"IPv4 address too long \""<sa.sa_family = AF_INET; sockaddr = (void*)&temp->in.sin_addr.s_addr; @@ -502,13 +505,8 @@ void SockAddr::setAddress(const char *name, unsigned short defport) } else if(openb) { // brackets // ipv6, maybe with port - size_t addrlen = closeb-openb-1u; - if(addrlen >= sizeof(scratch)) - throw std::runtime_error(SB()<<"IPv6 address too long \""< closeb) port = lastc+1; else @@ -892,4 +890,36 @@ void strDiff(std::ostream& out, } } -}} +} + +bool isHostname(const std::string& s) +{ + // strip port suffix and brackets to test the host part only + std::string host(s); + + if(!host.empty() && host.front() == '[') { + // bracketed IPv6: [::1]:port or [::1] + auto bracket = host.find(']'); + if(bracket != std::string::npos) + host = host.substr(1, bracket - 1); + } else { + // for non-bracketed: only strip port if there's exactly one colon (host:port) + auto first_colon = host.find(':'); + auto last_colon = host.rfind(':'); + if(first_colon != std::string::npos && first_colon == last_colon) + host = host.substr(0, first_colon); + } + + if(host.empty()) + return false; + + in_addr dummy4; + in6_addr dummy6; + if(evutil_inet_pton(AF_INET, host.c_str(), &dummy4) == 1) + return false; + if(evutil_inet_pton(AF_INET6, host.c_str(), &dummy6) == 1) + return false; + return true; +} + +} diff --git a/src/utilpvt.h b/src/utilpvt.h index 8d95bada6..7133baaf9 100644 --- a/src/utilpvt.h +++ b/src/utilpvt.h @@ -318,6 +318,9 @@ struct InstCounter { #define DEFINE_INST_COUNTER2(KLASS, NAME) std::atomic KLASS::cnt_ ## NAME {0u} #define DEFINE_INST_COUNTER(KLASS) DEFINE_INST_COUNTER2(KLASS, KLASS) +PVXS_API +bool isHostname(const std::string& s); + } // namespace pvxs #endif // UTILPVT_H diff --git a/test/Makefile b/test/Makefile index bfe42c33d..9dc733c23 100644 --- a/test/Makefile +++ b/test/Makefile @@ -187,6 +187,10 @@ TESTPROD_HOST += eatspam eatspam_SRCS += eatspam.cpp # not a unittest +TESTPROD_HOST += testdnsresolve +testdnsresolve_SRCS += testdnsresolve.cpp +TESTS += testdnsresolve + TESTSCRIPTS_HOST += $(TESTS:%=%.t) ifdef BASE_3_15 ifneq ($(filter $(T_A),$(CROSS_COMPILER_RUNTEST_ARCHS)),) diff --git a/test/testdnsresolve.cpp b/test/testdnsresolve.cpp new file mode 100644 index 000000000..a18373a2c --- /dev/null +++ b/test/testdnsresolve.cpp @@ -0,0 +1,127 @@ +/** + * Copyright - See the COPYRIGHT that is included with this distribution. + * pvxs is distributed subject to a Software License Agreement found + * in file LICENSE that is included with this distribution. + */ + +#include + +#include +#include +#include + +#include +#include +#include + +#include + +using namespace pvxs; + +namespace { + +// epicsEnvUnset() is not available on older EPICS base (e.g. 3.14) +void unsetEnv(const char *name) +{ +#ifdef _WIN32 + _putenv((std::string(name)+"=").c_str()); +#else + unsetenv(name); +#endif +} + +void test_isHostname() +{ + testDiag("%s", __func__); + + testTrue(isHostname("localhost")); + testTrue(isHostname("myhost.example.com")); + testTrue(isHostname("myhost:5075")); + testTrue(!isHostname("127.0.0.1")); + testTrue(!isHostname("127.0.0.1:5075")); + testTrue(!isHostname("::1")); + testTrue(!isHostname("[::1]:5075")); +} + +void test_config_hostname_preservation() +{ + testDiag("%s", __func__); + + client::Config conf; + conf.udp_port = 5076; + conf.tcp_port = 5075; + + epicsEnvSet("EPICS_PVA_NAME_SERVERS", "localhost:5075"); + epicsEnvSet("EPICS_PVA_ADDR_LIST", ""); + epicsEnvSet("EPICS_PVA_AUTO_ADDR_LIST", "NO"); + + conf.applyEnv(); + + testOk(conf.nameServers.size() == 1, "nameServers has one entry"); + testOk(conf.nameServerHostnames.size() == 1, "nameServerHostnames has one entry"); + + if(!conf.nameServerHostnames.empty()) { + auto it = conf.nameServerHostnames.begin(); + testOk(it->second == "localhost:5075", + "hostname preserved: '%s'", it->second.c_str()); + } else { + testSkip(1, "no hostname entries"); + } +} + +void test_config_hostname_no_port_defaults_tcp_port() +{ + testDiag("%s", __func__); + + client::Config conf; + conf.udp_port = 5076; + conf.tcp_port = 0; + + unsetEnv("EPICS_PVA_SERVER_PORT"); + epicsEnvSet("EPICS_PVA_NAME_SERVERS", "localhost"); + epicsEnvSet("EPICS_PVA_ADDR_LIST", ""); + epicsEnvSet("EPICS_PVA_AUTO_ADDR_LIST", "NO"); + + conf.applyEnv(); + + testOk(conf.tcp_port == 5075, "tcp_port defaulted to 5075 (got %u)", conf.tcp_port); + + testOk(conf.nameServers.size() == 1, "nameServers has one entry"); + if(!conf.nameServers.empty()) { + auto& ep = conf.nameServers[0]; + testOk(ep.find(":0") == std::string::npos, + "resolved nameserver endpoint doesn't carry port 0 ('%s')", ep.c_str()); + } else { + testSkip(1, "no nameServers entries"); + } +} + +void test_config_ip_no_hostname() +{ + testDiag("%s", __func__); + + client::Config conf; + epicsEnvSet("EPICS_PVA_NAME_SERVERS", "127.0.0.1:5075"); + epicsEnvSet("EPICS_PVA_ADDR_LIST", ""); + epicsEnvSet("EPICS_PVA_AUTO_ADDR_LIST", "NO"); + + conf.applyEnv(); + + testOk(conf.nameServerHostnames.empty(), + "no hostname stored for bare IP (size=%zu)", conf.nameServerHostnames.size()); +} + +} // namespace + +MAIN(testdnsresolve) +{ + SockAttach attach; + testPlan(14); + testSetup(); + test_isHostname(); + test_config_hostname_preservation(); + test_config_hostname_no_port_defaults_tcp_port(); + test_config_ip_no_hostname(); + cleanup_for_valgrind(); + return testDone(); +}