From cce6720e4f027c9f20c443ee1b979a30082de995 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 07:12:07 -0500 Subject: [PATCH 01/12] feat: Add IPv6 dual-stack support (#245) The server now binds to an AF_INET6 socket with IPV6_V6ONLY=0, which accepts both IPv4 and IPv6 connections simultaneously on a single port. On platforms that do not support dual-stack (e.g. OpenBSD), the server falls back to IPv4-only automatically. Changes: - ServerSocket::bind(): try AF_INET6 + IPV6_V6ONLY=0, fall back to AF_INET - ServerSocket::accept(): use sockaddr_storage; strip ::ffff: prefix from IPv4-mapped addresses so downstream code sees plain IPv4 strings - ClientSocket::connect(): use getaddrinfo(AF_UNSPEC) for name resolution, recreating the socket as AF_INET6 when the server address requires it - Ip class: store address as string (addrStr) to support IPv6 literals; byte array kept for backwards-compatible getByte()/Inet_NtoA() paths - getLocalIPAddressList(): replace deprecated gethostbyname() with getaddrinfo(AF_UNSPEC) to also enumerate IPv6 host addresses - getLocalIPAddressListForPlatform(): add AF_INET6 branches (POSIX getifaddrs and Windows GetAdaptersAddresses) to include global-scope IPv6 addresses - Socket::getIp(): replace gethostbyname() with getaddrinfo() - Socket::socketFamily: new member tracking AF_INET / AF_INET6 Note: FTP file-transfer validation (isValidClientType) still uses uint32 and will not work for pure-IPv6 clients; IPv4-mapped connections are unaffected because accept() strips the ::ffff: prefix. Co-Authored-By: Claude Sonnet 4.6 --- .../include/platform/posix/socket.h | 2 + .../sources/platform/posix/socket.cpp | 349 +++++++++++++----- 2 files changed, 262 insertions(+), 89 deletions(-) diff --git a/source/shared_lib/include/platform/posix/socket.h b/source/shared_lib/include/platform/posix/socket.h index 0db7b3bc3..624431efd 100644 --- a/source/shared_lib/include/platform/posix/socket.h +++ b/source/shared_lib/include/platform/posix/socket.h @@ -97,6 +97,7 @@ class DiscoveredServersInterface { class Ip { private: unsigned char bytes[4]; + std::string addrStr; // stores IPv6 or IPv4 strings; overrides bytes when non-empty public: Ip(); @@ -126,6 +127,7 @@ class Socket { // static SocketManager wsaManager; // #endif PLATFORM_SOCKET sock; + int socketFamily; // AF_INET or AF_INET6 time_t lastDebugEvent; static int broadcast_portno; std::string ipAddress; diff --git a/source/shared_lib/sources/platform/posix/socket.cpp b/source/shared_lib/sources/platform/posix/socket.cpp index 772d9507a..9f9fe39b9 100644 --- a/source/shared_lib/sources/platform/posix/socket.cpp +++ b/source/shared_lib/sources/platform/posix/socket.cpp @@ -286,20 +286,27 @@ Ip::Ip(unsigned char byte0, unsigned char byte1, unsigned char byte2, unsigned c } Ip::Ip(const string &ipString) { - size_t offset = 0; - int byteIndex = 0; + bytes[0] = 0; + bytes[1] = 0; + bytes[2] = 0; + bytes[3] = 0; + addrStr = ipString; - if (ipString.empty() == false) { - for (byteIndex = 0; byteIndex < 4; ++byteIndex) { + // For IPv4 dotted-decimal, also populate the bytes array for backwards compat + if (ipString.find(':') == string::npos && ipString.find('.') != string::npos) { + size_t offset = 0; + for (int byteIndex = 0; byteIndex < 4; ++byteIndex) { size_t dotPos = ipString.find_first_of('.', offset); - - bytes[byteIndex] = atoi(ipString.substr(offset, dotPos - offset).c_str()); + bytes[byteIndex] = (unsigned char)atoi(ipString.substr(offset, dotPos - offset).c_str()); offset = dotPos + 1; } } } string Ip::getString() const { + if (!addrStr.empty()) { + return addrStr; + } return intToStr(bytes[0]) + "." + intToStr(bytes[1]) + "." + intToStr(bytes[2]) + "." + intToStr(bytes[3]); } @@ -677,21 +684,42 @@ std::vector Socket::getLocalIPAddressList() { char myhostname[101] = ""; gethostname(myhostname, 100); - struct hostent *myhostent = gethostbyname(myhostname); - if (myhostent) { - // get all host IP addresses (Except for loopback) - char myhostaddr[101] = ""; - for (int ipIdx = 0; myhostent->h_addr_list[ipIdx] != NULL; ++ipIdx) { - Ip::Inet_NtoA(SockAddrToUint32((struct in_addr *)myhostent->h_addr_list[ipIdx]), myhostaddr); - - // printf("ipIdx = %d [%s]\n",ipIdx,myhostaddr); - if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) - SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] myhostaddr = [%s]\n", __FILE__, __FUNCTION__, __LINE__, myhostaddr); - - if (strlen(myhostaddr) > 0 && strncmp(myhostaddr, "127.", 4) != 0 && strncmp(myhostaddr, "0.", 2) != 0) { - ipList.push_back(myhostaddr); + // Use getaddrinfo to enumerate host addresses (supports both IPv4 and IPv6) + struct addrinfo hints, *res = NULL, *rp = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo(myhostname, NULL, &hints, &res) == 0) { + for (rp = res; rp != NULL; rp = rp->ai_next) { + char myhostaddr[INET6_ADDRSTRLEN] = ""; + if (rp->ai_family == AF_INET) { + struct sockaddr_in *sa = (struct sockaddr_in *)rp->ai_addr; + inet_ntop(AF_INET, &sa->sin_addr, myhostaddr, sizeof(myhostaddr)); + if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] myhostaddr (v4) = [%s]\n", __FILE__, __FUNCTION__, __LINE__, + myhostaddr); + if (strlen(myhostaddr) > 0 && strncmp(myhostaddr, "127.", 4) != 0 && strncmp(myhostaddr, "0.", 2) != 0) { + if (std::find(ipList.begin(), ipList.end(), myhostaddr) == ipList.end()) { + ipList.push_back(myhostaddr); + } + } + } else if (rp->ai_family == AF_INET6) { + struct sockaddr_in6 *sa6 = (struct sockaddr_in6 *)rp->ai_addr; + if (IN6_IS_ADDR_LOOPBACK(&sa6->sin6_addr) || IN6_IS_ADDR_LINKLOCAL(&sa6->sin6_addr)) { + continue; + } + inet_ntop(AF_INET6, &sa6->sin6_addr, myhostaddr, sizeof(myhostaddr)); + if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] myhostaddr (v6) = [%s]\n", __FILE__, __FUNCTION__, __LINE__, + myhostaddr); + if (strlen(myhostaddr) > 0) { + if (std::find(ipList.begin(), ipList.end(), myhostaddr) == ipList.end()) { + ipList.push_back(myhostaddr); + } + } } } + freeaddrinfo(res); } Socket::getLocalIPAddressListForPlatform(ipList); @@ -700,25 +728,40 @@ std::vector Socket::getLocalIPAddressList() { #ifndef WIN32 void Socket::getLocalIPAddressListForPlatform(std::vector &ipList) { - // Now check all linux network devices + // Now check all network devices, including IPv6 struct ifaddrs *ifap = NULL; getifaddrs(&ifap); for (struct ifaddrs *ifa = ifap; ifa != NULL; ifa = ifa->ifa_next) { if (!ifa->ifa_addr) { continue; } - if (ifa->ifa_addr->sa_family == AF_INET) { // check it is IP4 - // is a valid IP4 Address + char addrBuf[INET6_ADDRSTRLEN] = ""; + if (ifa->ifa_addr->sa_family == AF_INET) { struct sockaddr_in *sa = (struct sockaddr_in *)ifa->ifa_addr; - char *addr = inet_ntoa(sa->sin_addr); - // printf("Interface: %s\tAddress: %s\n", ifa->ifa_name, addr); + inet_ntop(AF_INET, &sa->sin_addr, addrBuf, sizeof(addrBuf)); if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) { - SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] Interface: [%s] address: [%s]\n", __FILE__, __FUNCTION__, __LINE__, - ifa->ifa_name, addr); + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] Interface: [%s] IPv4 address: [%s]\n", __FILE__, __FUNCTION__, + __LINE__, ifa->ifa_name, addrBuf); } - if (strlen(addr) > 0 && strncmp(addr, "127.", 4) != 0 && strncmp(addr, "0.", 2) != 0) { - if (std::find(ipList.begin(), ipList.end(), addr) == ipList.end()) { - ipList.push_back(addr); + if (strlen(addrBuf) > 0 && strncmp(addrBuf, "127.", 4) != 0 && strncmp(addrBuf, "0.", 2) != 0) { + if (std::find(ipList.begin(), ipList.end(), addrBuf) == ipList.end()) { + ipList.push_back(addrBuf); + } + } + } else if (ifa->ifa_addr->sa_family == AF_INET6) { + struct sockaddr_in6 *sa6 = (struct sockaddr_in6 *)ifa->ifa_addr; + // Skip loopback (::1) and link-local (fe80::/10) + if (IN6_IS_ADDR_LOOPBACK(&sa6->sin6_addr) || IN6_IS_ADDR_LINKLOCAL(&sa6->sin6_addr)) { + continue; + } + inet_ntop(AF_INET6, &sa6->sin6_addr, addrBuf, sizeof(addrBuf)); + if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) { + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] Interface: [%s] IPv6 address: [%s]\n", __FILE__, __FUNCTION__, + __LINE__, ifa->ifa_name, addrBuf); + } + if (strlen(addrBuf) > 0) { + if (std::find(ipList.begin(), ipList.end(), addrBuf) == ipList.end()) { + ipList.push_back(addrBuf); } } } @@ -801,15 +844,13 @@ void Socket::getLocalIPAddressListForPlatform(std::vector &ipList) #ifdef WIN32 void Socket::getLocalIPAddressListForPlatform(std::vector &ipList) { ULONG outBufLen = 0; - GetAdaptersAddresses(AF_INET, 0, NULL, NULL, &outBufLen); + GetAdaptersAddresses(AF_UNSPEC, 0, NULL, NULL, &outBufLen); PIP_ADAPTER_ADDRESSES pAddresses = (IP_ADAPTER_ADDRESSES *)malloc(outBufLen); - GetAdaptersAddresses(AF_INET, GAA_FLAG_SKIP_ANYCAST, NULL, pAddresses, &outBufLen); + GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_ANYCAST, NULL, pAddresses, &outBufLen); PIP_ADAPTER_ADDRESSES pCurrAddresses = NULL; PIP_ADAPTER_UNICAST_ADDRESS pUnicast = NULL; LPSOCKADDR addr = NULL; pCurrAddresses = pAddresses; - // char buff[100]; - DWORD bufflen = 100; while (pCurrAddresses) { if (pCurrAddresses->OperStatus != IfOperStatusUp) { pCurrAddresses = pCurrAddresses->Next; @@ -818,16 +859,29 @@ void Socket::getLocalIPAddressListForPlatform(std::vector &ipList) pUnicast = pCurrAddresses->FirstUnicastAddress; while (pUnicast) { addr = pUnicast->Address.lpSockaddr; + char addrBuf[INET6_ADDRSTRLEN] = ""; if (addr->sa_family == AF_INET && pCurrAddresses->IfType != MIB_IF_TYPE_LOOPBACK) { sockaddr_in *sa_in = (sockaddr_in *)addr; - char *strIP = ::inet_ntoa((sa_in->sin_addr)); - // printf("\tIPV4:%s\n", strIP); + inet_ntop(AF_INET, &sa_in->sin_addr, addrBuf, sizeof(addrBuf)); if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) { - SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] strIP [%s]\n", __FILE__, __FUNCTION__, __LINE__, strIP); + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] IPv4 [%s]\n", __FILE__, __FUNCTION__, __LINE__, addrBuf); } - if (strlen(strIP) > 0 && strncmp(strIP, "127.", 4) != 0 && strncmp(strIP, "0.", 2) != 0) { - if (std::find(ipList.begin(), ipList.end(), strIP) == ipList.end()) { - ipList.push_back(strIP); + if (strlen(addrBuf) > 0 && strncmp(addrBuf, "127.", 4) != 0 && strncmp(addrBuf, "0.", 2) != 0) { + if (std::find(ipList.begin(), ipList.end(), addrBuf) == ipList.end()) { + ipList.push_back(addrBuf); + } + } + } else if (addr->sa_family == AF_INET6 && pCurrAddresses->IfType != MIB_IF_TYPE_LOOPBACK) { + sockaddr_in6 *sa_in6 = (sockaddr_in6 *)addr; + if (!IN6_IS_ADDR_LOOPBACK(&sa_in6->sin6_addr) && !IN6_IS_ADDR_LINKLOCAL(&sa_in6->sin6_addr)) { + inet_ntop(AF_INET6, &sa_in6->sin6_addr, addrBuf, sizeof(addrBuf)); + if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) { + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] IPv6 [%s]\n", __FILE__, __FUNCTION__, __LINE__, addrBuf); + } + if (strlen(addrBuf) > 0) { + if (std::find(ipList.begin(), ipList.end(), addrBuf) == ipList.end()) { + ipList.push_back(addrBuf); + } } } } @@ -878,6 +932,7 @@ Socket::Socket(PLATFORM_SOCKET sock) { dataSynchAccessorWrite->setOwnerId(CODE_AT_LINE); this->sock = sock; + this->socketFamily = AF_INET; this->isSocketBlocking = true; this->connectedIpAddress = ""; } @@ -901,6 +956,7 @@ Socket::Socket() { // this->pingThread = NULL; this->connectedIpAddress = ""; + this->socketFamily = AF_INET; sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (isSocketValid() == false) { @@ -2141,20 +2197,37 @@ string Socket::getHostName() { } string Socket::getIp() { - hostent *info = gethostbyname(getHostName().c_str()); - unsigned char *address; - - if (info == NULL) { - throw megaglest_runtime_error("Error getting host by name"); - } - - address = reinterpret_cast(info->h_addr_list[0]); - - if (address == NULL) { + struct addrinfo hints, *res = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_INET; // prefer IPv4 for backwards compat + hints.ai_socktype = SOCK_STREAM; + + int err = getaddrinfo(getHostName().c_str(), NULL, &hints, &res); + if (err != 0 || res == NULL) { + // Fall back to any address family + if (res) freeaddrinfo(res); + hints.ai_family = AF_UNSPEC; + err = getaddrinfo(getHostName().c_str(), NULL, &hints, &res); + if (err != 0 || res == NULL) { + if (res) freeaddrinfo(res); + throw megaglest_runtime_error("Error getting host ip"); + } + } + + char addrBuf[INET6_ADDRSTRLEN] = ""; + if (res->ai_family == AF_INET) { + struct sockaddr_in *sa = (struct sockaddr_in *)res->ai_addr; + inet_ntop(AF_INET, &sa->sin_addr, addrBuf, sizeof(addrBuf)); + } else if (res->ai_family == AF_INET6) { + struct sockaddr_in6 *sa6 = (struct sockaddr_in6 *)res->ai_addr; + inet_ntop(AF_INET6, &sa6->sin6_addr, addrBuf, sizeof(addrBuf)); + } + freeaddrinfo(res); + + if (addrBuf[0] == '\0') { throw megaglest_runtime_error("Error getting host ip"); } - - return intToStr(address[0]) + "." + intToStr(address[1]) + "." + intToStr(address[2]) + "." + intToStr(address[3]); + return addrBuf; } void Socket::throwException(string str) { @@ -2224,29 +2297,71 @@ void ClientSocket::discoverServers(DiscoveredServersInterface *cb) { } void ClientSocket::connect(const Ip &ip, int port) { - sockaddr_in addr; - memset(&addr, 0, sizeof(addr)); - - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = inet_addr(ip.getString().c_str()); - addr.sin_port = htons(port); + string ipStr = ip.getString(); if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) - SystemFlags::OutputDebug(SystemFlags::debugNetwork, "Connecting to host [%s] on port = %d\n", ip.getString().c_str(), port); - if (SystemFlags::VERBOSE_MODE_ENABLED) printf("Connecting to host [%s] on port = %d\n", ip.getString().c_str(), port); + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "Connecting to host [%s] on port = %d\n", ipStr.c_str(), port); + if (SystemFlags::VERBOSE_MODE_ENABLED) printf("Connecting to host [%s] on port = %d\n", ipStr.c_str(), port); + + // Resolve the address using getaddrinfo, which supports both IPv4 and IPv6. + char portStr[16]; + snprintf(portStr, sizeof(portStr), "%d", port); + struct addrinfo hints, *res = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + int gai_err = getaddrinfo(ipStr.c_str(), portStr, &hints, &res); + if (gai_err != 0 || res == NULL) { + if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) + SystemFlags::OutputDebug(SystemFlags::debugNetwork, + "In [%s::%s Line: %d] getaddrinfo failed for [%s]: %s\n", + __FILE__, __FUNCTION__, __LINE__, ipStr.c_str(), gai_strerror(gai_err)); + if (res) freeaddrinfo(res); + disconnectSocket(); + return; + } + + // If the resolved family differs from the current socket family, recreate + // the socket so it matches (e.g. upgrade AF_INET to AF_INET6). + if (res->ai_family != socketFamily) { + disconnectSocket(); + sock = socket(res->ai_family, SOCK_STREAM, IPPROTO_TCP); + if (isSocketValid() == false) { + freeaddrinfo(res); + throwException("Error creating socket for connect"); + } + socketFamily = res->ai_family; + setBlock(false); +#ifdef __APPLE__ + { + int set = 1; + setsockopt(sock, SOL_SOCKET, SO_NOSIGPIPE, (void *)&set, sizeof(int)); + } +#endif + if (Socket::disableNagle == true) { + int flag = 1; + setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (char *)&flag, sizeof(flag)); + } + } + + struct sockaddr_storage addr; + memcpy(&addr, res->ai_addr, res->ai_addrlen); + socklen_t addrlen = (socklen_t)res->ai_addrlen; + freeaddrinfo(res); connectedIpAddress = ""; - int err = ::connect(sock, reinterpret_cast(&addr), sizeof(addr)); + int err = ::connect(sock, reinterpret_cast(&addr), addrlen); if (err < 0) { if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] #2 Error connecting socket for IP: %s for " "Port: %d err = %d error = %s\n", - __FILE__, __FUNCTION__, __LINE__, ip.getString().c_str(), port, err, getLastSocketErrorFormattedText().c_str()); + __FILE__, __FUNCTION__, __LINE__, ipStr.c_str(), port, err, getLastSocketErrorFormattedText().c_str()); if (SystemFlags::VERBOSE_MODE_ENABLED) printf("In [%s::%s Line: %d] #2 Error connecting socket for IP: %s for " "Port: %d err = %d error = %s\n", - __FILE__, __FUNCTION__, __LINE__, ip.getString().c_str(), port, err, getLastSocketErrorFormattedText().c_str()); + __FILE__, __FUNCTION__, __LINE__, ipStr.c_str(), port, err, getLastSocketErrorFormattedText().c_str()); int lastSocketError = getLastSocketError(); if (lastSocketError == PLATFORM_SOCKET_INPROGRESS || lastSocketError == PLATFORM_SOCKET_TRY_AGAIN) { @@ -2362,17 +2477,17 @@ void ClientSocket::connect(const Ip &ip, int port) { printf("In [%s::%s Line: %d] Valid recovery for connection sock " "= " PLATFORM_SOCKET_FORMAT_TYPE ", err = %d, error = %s\n", __FILE__, __FUNCTION__, __LINE__, sock, err, getLastSocketErrorFormattedText().c_str()); - connectedIpAddress = ip.getString(); + connectedIpAddress = ipStr; } } else { if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) - SystemFlags::OutputDebug(SystemFlags::debugNetwork, "Connected to host [%s] on port = %d sock = %d err = %d", ip.getString().c_str(), port, sock, + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "Connected to host [%s] on port = %d sock = %d err = %d", ipStr.c_str(), port, sock, err); if (SystemFlags::VERBOSE_MODE_ENABLED) printf("Connected to host [%s] on port = %d sock " "= " PLATFORM_SOCKET_FORMAT_TYPE " err = %d", - ip.getString().c_str(), port, sock, err); - connectedIpAddress = ip.getString(); + ipStr.c_str(), port, sock, err); + connectedIpAddress = ipStr; } } @@ -2681,34 +2796,78 @@ void ServerSocket::bind(int port) { boundPort = port; - if (isSocketValid() == false) { + // Close any existing socket so we can choose the right address family. + if (isSocketValid()) { + disconnectSocket(); + portBound = false; + } + + // When no specific address is requested, try an IPv6 dual-stack socket + // (IPV6_V6ONLY=0) so the server accepts both IPv4 and IPv6 connections on + // a single socket. If the platform does not support dual-stack (e.g. + // OpenBSD where IPV6_V6ONLY cannot be cleared), fall back to IPv4. + bool useIPv6 = (this->bindSpecificAddress == ""); + + if (useIPv6) { + sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP); + if (isSocketValid()) { + int v6only = 0; +#ifndef WIN32 + if (setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) != 0) { +#else + if (setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, (char *)&v6only, sizeof(v6only)) != 0) { +#endif + // Cannot clear IPV6_V6ONLY — fall back to IPv4-only + disconnectSocket(); + portBound = false; + useIPv6 = false; + } + } else { + useIPv6 = false; + } + if (useIPv6) { + socketFamily = AF_INET6; + } + } + + if (!useIPv6) { sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (isSocketValid() == false) { throwException("Error creating socket"); } - setBlock(false); + socketFamily = AF_INET; } - // sockaddr structure - sockaddr_in addr; - addr.sin_family = AF_INET; - if (this->bindSpecificAddress != "") { - addr.sin_addr.s_addr = inet_addr(this->bindSpecificAddress.c_str()); - } else { - addr.sin_addr.s_addr = INADDR_ANY; - } - addr.sin_port = htons(port); - addr.sin_zero[0] = 0; + setBlock(false); int val = 1; - #ifndef WIN32 int opt_result = setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val)); #else int opt_result = setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (char *)&val, sizeof(val)); #endif - int err = ::bind(sock, reinterpret_cast(&addr), sizeof(addr)); + int err = -1; + if (useIPv6) { + sockaddr_in6 addr6; + memset(&addr6, 0, sizeof(addr6)); + addr6.sin6_family = AF_INET6; + addr6.sin6_addr = in6addr_any; + addr6.sin6_port = htons(port); + err = ::bind(sock, reinterpret_cast(&addr6), sizeof(addr6)); + } else { + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + if (this->bindSpecificAddress != "") { + addr.sin_addr.s_addr = inet_addr(this->bindSpecificAddress.c_str()); + } else { + addr.sin_addr.s_addr = INADDR_ANY; + } + addr.sin_port = htons(port); + err = ::bind(sock, reinterpret_cast(&addr), sizeof(addr)); + } + if (err < 0) { char szBuf[8096] = ""; snprintf(szBuf, 8096, @@ -2723,8 +2882,8 @@ void ServerSocket::bind(int port) { portBound = true; if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) - SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s] Line: %d port = %d, portBound = %d END\n", __FILE__, __FUNCTION__, __LINE__, port, - portBound); + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s] Line: %d port = %d, portBound = %d (IPv6=%d) END\n", __FILE__, __FUNCTION__, __LINE__, + port, portBound, useIPv6 ? 1 : 0); } void ServerSocket::disconnectSocket() { @@ -2794,7 +2953,7 @@ Socket *ServerSocket::accept(bool errorOnFail) { char client_host[100] = ""; // const int max_attempts = 100; // for(int attempt = 0; attempt < max_attempts; ++attempt) { - struct sockaddr_in cli_addr; + struct sockaddr_storage cli_addr; socklen_t clilen = sizeof(cli_addr); client_host[0] = '\0'; MutexSafeWrapper safeMutex(dataSynchAccessorRead, CODE_AT_LINE); @@ -2812,12 +2971,7 @@ Socket *ServerSocket::accept(bool errorOnFail) { int lastSocketError = getLastSocketError(); if (lastSocketError == PLATFORM_SOCKET_TRY_AGAIN) { - // if(attempt+1 >= max_attempts) { - // return NULL; - // } - // else { sleep(0); - //} } if (errorOnFail == true) { throwException(szBuf); @@ -2834,7 +2988,24 @@ Socket *ServerSocket::accept(bool errorOnFail) { } } else { - Ip::Inet_NtoA(SockAddrToUint32((struct sockaddr *)&cli_addr), client_host); + // Extract client address string, supporting both IPv4 and IPv6. + // When the server socket is dual-stack (AF_INET6 + IPV6_V6ONLY=0), + // IPv4 clients appear as IPv4-mapped IPv6 addresses (::ffff:x.x.x.x). + // Strip the prefix so the rest of the code sees a plain IPv4 address. + if (cli_addr.ss_family == AF_INET6) { + struct sockaddr_in6 *sa6 = (struct sockaddr_in6 *)&cli_addr; + if (IN6_IS_ADDR_V4MAPPED(&sa6->sin6_addr)) { + // Extract the embedded IPv4 address + struct in_addr v4addr; + memcpy(&v4addr, sa6->sin6_addr.s6_addr + 12, 4); + inet_ntop(AF_INET, &v4addr, client_host, sizeof(client_host)); + } else { + inet_ntop(AF_INET6, &sa6->sin6_addr, client_host, sizeof(client_host)); + } + } else { + struct sockaddr_in *sa4 = (struct sockaddr_in *)&cli_addr; + inet_ntop(AF_INET, &sa4->sin_addr, client_host, sizeof(client_host)); + } if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] got connection, newSock = " From 0fa9f826dcb55a9fd84beb358c95c7d7c653ca4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 24 Mar 2026 12:29:35 +0000 Subject: [PATCH 02/12] style: Apply clang-format --- source/shared_lib/sources/platform/posix/socket.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/source/shared_lib/sources/platform/posix/socket.cpp b/source/shared_lib/sources/platform/posix/socket.cpp index 9f9fe39b9..f1e731c5e 100644 --- a/source/shared_lib/sources/platform/posix/socket.cpp +++ b/source/shared_lib/sources/platform/posix/socket.cpp @@ -2314,9 +2314,8 @@ void ClientSocket::connect(const Ip &ip, int port) { int gai_err = getaddrinfo(ipStr.c_str(), portStr, &hints, &res); if (gai_err != 0 || res == NULL) { if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) - SystemFlags::OutputDebug(SystemFlags::debugNetwork, - "In [%s::%s Line: %d] getaddrinfo failed for [%s]: %s\n", - __FILE__, __FUNCTION__, __LINE__, ipStr.c_str(), gai_strerror(gai_err)); + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "In [%s::%s Line: %d] getaddrinfo failed for [%s]: %s\n", __FILE__, __FUNCTION__, __LINE__, + ipStr.c_str(), gai_strerror(gai_err)); if (res) freeaddrinfo(res); disconnectSocket(); return; @@ -2481,8 +2480,7 @@ void ClientSocket::connect(const Ip &ip, int port) { } } else { if (SystemFlags::getSystemSettingType(SystemFlags::debugNetwork).enabled) - SystemFlags::OutputDebug(SystemFlags::debugNetwork, "Connected to host [%s] on port = %d sock = %d err = %d", ipStr.c_str(), port, sock, - err); + SystemFlags::OutputDebug(SystemFlags::debugNetwork, "Connected to host [%s] on port = %d sock = %d err = %d", ipStr.c_str(), port, sock, err); if (SystemFlags::VERBOSE_MODE_ENABLED) printf("Connected to host [%s] on port = %d sock " "= " PLATFORM_SOCKET_FORMAT_TYPE " err = %d", From f994a331dfc061cc949cdcd19c14e276025fad00 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 07:38:19 -0500 Subject: [PATCH 03/12] fix: Repair IPv4 connect and IPv6 address entry in join-game UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions introduced by the IPv6 dual-stack commit: 1. 127.0.0.1 connect broken: the UI appends '_' as a cursor character to label text (e.g. "127.0.0.1_"). The old Ip(string) constructor used atoi() which silently ignored the trailing '_', but the new constructor stored the raw string verbatim so getaddrinfo() received "127.0.0.1_" and failed. Fix: strip trailing '_' characters in Ip(const string&) before storing in addrStr. 2. Entering "::1" reverted to "0.0.0.0": the host:port parser in MenuStateJoinGame split the text on every ':' character, so "::1" became host="" → Ip("") → 0.0.0.0. Fix: treat strings with more than one colon as bare IPv6 addresses (no port), and support the standard [addr]:port bracket notation for IPv6 with a port override. Co-Authored-By: Claude Sonnet 4.6 --- .../glest_game/menu/menu_state_join_game.cpp | 54 +++++++++++++++---- .../sources/platform/posix/socket.cpp | 14 +++-- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/source/glest_game/menu/menu_state_join_game.cpp b/source/glest_game/menu/menu_state_join_game.cpp index e6737bae0..bcdaa6e69 100644 --- a/source/glest_game/menu/menu_state_join_game.cpp +++ b/source/glest_game/menu/menu_state_join_game.cpp @@ -208,12 +208,28 @@ void MenuStateJoinGame::CommonInit(bool connect, Ip serverIp, int portNumberOver host = labelServerIp.getText(); portNumber = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); - hostPartsList.clear(); - Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() > 1) { - host = hostPartsList[0]; - replaceAll(hostPartsList[1], "_", ""); - portNumber = strToInt(hostPartsList[1]); + // Strip trailing '_' cursor character before parsing + replaceAll(host, "_", ""); + // Parse host:port, being careful not to split on colons inside IPv6 addresses. + // Bracket notation [addr]:port is the standard for IPv6 with a port. + // A bare IPv6 address (more than one colon) has no port component. + if (!host.empty() && host[0] == '[') { + size_t close = host.find(']'); + if (close != string::npos) { + if (close + 1 < host.size() && host[close + 1] == ':') { + portNumber = strToInt(host.substr(close + 2)); + } + host = host.substr(1, close - 1); + } + } else { + hostPartsList.clear(); + Tokenize(host, hostPartsList, ":"); + if (hostPartsList.size() == 2) { + // Exactly one colon: IPv4 host:port + host = hostPartsList[0]; + portNumber = strToInt(hostPartsList[1]); + } + // More than two parts means an IPv6 address — leave host unchanged } port = " (" + intToStr(portNumber) + ")"; @@ -755,11 +771,27 @@ bool MenuStateJoinGame::connectToServer() { string host = labelServerIp.getText(); int port = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); std::vector hostPartsList; - Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() > 1) { - host = hostPartsList[0]; - replaceAll(hostPartsList[1], "_", ""); - port = strToInt(hostPartsList[1]); + // Strip trailing '_' cursor character before parsing + replaceAll(host, "_", ""); + // Parse host:port, being careful not to split on colons inside IPv6 addresses. + // Bracket notation [addr]:port is the standard for IPv6 with a port. + // A bare IPv6 address (more than one colon) has no port component. + if (!host.empty() && host[0] == '[') { + size_t close = host.find(']'); + if (close != string::npos) { + if (close + 1 < host.size() && host[close + 1] == ':') { + port = strToInt(host.substr(close + 2)); + } + host = host.substr(1, close - 1); + } + } else { + Tokenize(host, hostPartsList, ":"); + if (hostPartsList.size() == 2) { + // Exactly one colon: IPv4 host:port + host = hostPartsList[0]; + port = strToInt(hostPartsList[1]); + } + // More than two parts means an IPv6 address — leave host unchanged } Ip serverIp(host); diff --git a/source/shared_lib/sources/platform/posix/socket.cpp b/source/shared_lib/sources/platform/posix/socket.cpp index f1e731c5e..925ee1c8a 100644 --- a/source/shared_lib/sources/platform/posix/socket.cpp +++ b/source/shared_lib/sources/platform/posix/socket.cpp @@ -290,14 +290,20 @@ Ip::Ip(const string &ipString) { bytes[1] = 0; bytes[2] = 0; bytes[3] = 0; - addrStr = ipString; + + // Strip trailing '_' cursor characters appended by the UI text labels + string clean = ipString; + while (!clean.empty() && clean.back() == '_') { + clean.pop_back(); + } + addrStr = clean; // For IPv4 dotted-decimal, also populate the bytes array for backwards compat - if (ipString.find(':') == string::npos && ipString.find('.') != string::npos) { + if (clean.find(':') == string::npos && clean.find('.') != string::npos) { size_t offset = 0; for (int byteIndex = 0; byteIndex < 4; ++byteIndex) { - size_t dotPos = ipString.find_first_of('.', offset); - bytes[byteIndex] = (unsigned char)atoi(ipString.substr(offset, dotPos - offset).c_str()); + size_t dotPos = clean.find_first_of('.', offset); + bytes[byteIndex] = (unsigned char)atoi(clean.substr(offset, dotPos - offset).c_str()); offset = dotPos + 1; } } From 06a960704a8ecb75403bb7800ad4a4ecd9198960 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 07:47:55 -0500 Subject: [PATCH 04/12] fix: Restore LAN auto-find by handling IPv4:port:port in host parser The LAN discovery callback builds the server entry as "ip:port" and then appends ":port" again, producing "ip:61357:61357" in the label. The old tokenizer handled this by taking parts[0] and parts[1] whenever size > 1. The new IPv6-aware tokenizer treated size > 2 as an IPv6 address and left the string unchanged, so connectToServer() received "ip:61357:61357" as the host. Fix: when splitting on ':', treat the result as IPv4 host:port if the first segment contains a dot (IPv4 addresses always do; IPv6 hex groups never do). This correctly handles the duplicate-port suffix while still treating bare "::1" and "2001:db8::1" as IPv6 addresses. Co-Authored-By: Claude Sonnet 4.6 --- source/glest_game/menu/menu_state_join_game.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/source/glest_game/menu/menu_state_join_game.cpp b/source/glest_game/menu/menu_state_join_game.cpp index bcdaa6e69..5e5ab1780 100644 --- a/source/glest_game/menu/menu_state_join_game.cpp +++ b/source/glest_game/menu/menu_state_join_game.cpp @@ -212,7 +212,9 @@ void MenuStateJoinGame::CommonInit(bool connect, Ip serverIp, int portNumberOver replaceAll(host, "_", ""); // Parse host:port, being careful not to split on colons inside IPv6 addresses. // Bracket notation [addr]:port is the standard for IPv6 with a port. - // A bare IPv6 address (more than one colon) has no port component. + // IPv4 addresses always contain dots; IPv6 hex groups never do — use that + // to distinguish "192.168.1.1:61357:61357" (IPv4 + duplicate port suffix) + // from "2001:db8::1" (IPv6). if (!host.empty() && host[0] == '[') { size_t close = host.find(']'); if (close != string::npos) { @@ -224,12 +226,12 @@ void MenuStateJoinGame::CommonInit(bool connect, Ip serverIp, int portNumberOver } else { hostPartsList.clear(); Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() == 2) { - // Exactly one colon: IPv4 host:port + bool isIPv4OrHostname = (hostPartsList.size() == 2) || (hostPartsList.size() > 2 && hostPartsList[0].find('.') != string::npos); + if (hostPartsList.size() >= 2 && isIPv4OrHostname) { host = hostPartsList[0]; portNumber = strToInt(hostPartsList[1]); } - // More than two parts means an IPv6 address — leave host unchanged + // Multiple colons with no dots in first segment → IPv6, leave host unchanged } port = " (" + intToStr(portNumber) + ")"; @@ -786,12 +788,12 @@ bool MenuStateJoinGame::connectToServer() { } } else { Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() == 2) { - // Exactly one colon: IPv4 host:port + bool isIPv4OrHostname = (hostPartsList.size() == 2) || (hostPartsList.size() > 2 && hostPartsList[0].find('.') != string::npos); + if (hostPartsList.size() >= 2 && isIPv4OrHostname) { host = hostPartsList[0]; port = strToInt(hostPartsList[1]); } - // More than two parts means an IPv6 address — leave host unchanged + // Multiple colons with no dots in first segment → IPv6, leave host unchanged } Ip serverIp(host); From 7a064803d32d066e384ab0199726774c047aed69 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 07:59:43 -0500 Subject: [PATCH 05/12] test: Add CppUnit tests for Ip class IPv4/IPv6 behaviour Covers the cases fixed by the IPv6 dual-stack work: - IPv4 string round-trip - Trailing '_' cursor character is stripped (UI regression fix) - Byte constructor still works - IPv6 string stored and returned unchanged - IPv6 cursor character stripped - Empty string yields 0.0.0.0 Also adds shared_lib/platform to the test directory glob in source/tests/CMakeLists.txt so new platform tests are picked up automatically. Co-Authored-By: Claude Sonnet 4.6 --- source/tests/CMakeLists.txt | 1 + .../tests/shared_lib/platform/socket_test.cpp | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 source/tests/shared_lib/platform/socket_test.cpp diff --git a/source/tests/CMakeLists.txt b/source/tests/CMakeLists.txt index f8b1c6676..82c89f8ba 100644 --- a/source/tests/CMakeLists.txt +++ b/source/tests/CMakeLists.txt @@ -72,6 +72,7 @@ IF(BUILD_MEGAGLEST_TESTS) SET(DIRS_WITH_SRC ./ shared_lib/graphics + shared_lib/platform shared_lib/util shared_lib/xml) diff --git a/source/tests/shared_lib/platform/socket_test.cpp b/source/tests/shared_lib/platform/socket_test.cpp new file mode 100644 index 000000000..908fdc277 --- /dev/null +++ b/source/tests/shared_lib/platform/socket_test.cpp @@ -0,0 +1,65 @@ +// ============================================================== +// This file is part of MegaGlest Unit Tests (www.megaglest.org) +// +// You can redistribute this code and/or modify it under +// the terms of the GNU General Public License as published +// by the Free Software Foundation; either version 2 of the +// License, or (at your option) any later version +// ============================================================== + +#include +#include "socket.h" + +using namespace Shared::Platform; + +class SocketTest : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(SocketTest); + + CPPUNIT_TEST(test_ip_ipv4_string); + CPPUNIT_TEST(test_ip_ipv4_cursor_stripped); + CPPUNIT_TEST(test_ip_ipv4_bytes); + CPPUNIT_TEST(test_ip_ipv6_string); + CPPUNIT_TEST(test_ip_ipv6_cursor_stripped); + CPPUNIT_TEST(test_ip_empty_string); + + CPPUNIT_TEST_SUITE_END(); + + public: + // Plain IPv4 address round-trips through the string constructor + void test_ip_ipv4_string() { + Ip ip("127.0.0.1"); + CPPUNIT_ASSERT_EQUAL(std::string("127.0.0.1"), ip.getString()); + } + + // The UI appends '_' as a cursor — it must be stripped + void test_ip_ipv4_cursor_stripped() { + Ip ip("192.168.1.100_"); + CPPUNIT_ASSERT_EQUAL(std::string("192.168.1.100"), ip.getString()); + } + + // Byte constructor still works (used by legacy code) + void test_ip_ipv4_bytes() { + Ip ip(10, 0, 0, 1); + CPPUNIT_ASSERT_EQUAL(std::string("10.0.0.1"), ip.getString()); + } + + // IPv6 loopback address is stored and returned unchanged + void test_ip_ipv6_string() { + Ip ip("::1"); + CPPUNIT_ASSERT_EQUAL(std::string("::1"), ip.getString()); + } + + // UI cursor character is stripped from IPv6 addresses too + void test_ip_ipv6_cursor_stripped() { + Ip ip("::1_"); + CPPUNIT_ASSERT_EQUAL(std::string("::1"), ip.getString()); + } + + // Empty string gives the zero address + void test_ip_empty_string() { + Ip ip(""); + CPPUNIT_ASSERT_EQUAL(std::string("0.0.0.0"), ip.getString()); + } +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(SocketTest); From 96907a1339ab582d3baa66a04a166c9c0d092fad Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 08:01:14 -0500 Subject: [PATCH 06/12] docs: Document how to run unit tests manually Co-Authored-By: Claude Sonnet 4.6 --- BUILD.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/BUILD.md b/BUILD.md index e89e183da..4e06bff63 100644 --- a/BUILD.md +++ b/BUILD.md @@ -97,6 +97,27 @@ cd builddir cmake -LH ``` +## Unit tests + +Unit tests are built and run automatically as part of the normal build (enabled +by default in `build-mg.sh`). After a successful build the test binary is at: + + mk/linux/megaglest_tests # Linux / macOS + +You can run all tests manually: + + ./mk/linux/megaglest_tests + +To run only a specific test suite or a single test: + + ./mk/linux/megaglest_tests SocketTest + ./mk/linux/megaglest_tests SocketTest::test_ip_ipv6_cursor_stripped + +Tests live under `source/tests/`. To add tests for a new subsystem, create a +subdirectory under `source/tests/shared_lib/` and add it to `DIRS_WITH_SRC` in +`source/tests/CMakeLists.txt` — the glob will pick up any `.cpp` files there +automatically. + ## Installing from manual build > [!CAUTION] From 487e9d90e41f51efb633e29650fd4b4f69c87a32 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 08:10:35 -0500 Subject: [PATCH 07/12] tests: support optional test-path argument in test runner Pass argv[1] to runner.run() so that specific suites or individual tests can be selected from the command line, e.g.: ./megaglest_tests SocketTest ./megaglest_tests SocketTest::test_ip_ipv6_cursor_stripped Co-Authored-By: Claude Sonnet 4.6 --- source/tests/test_runner.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/source/tests/test_runner.cpp b/source/tests/test_runner.cpp index 4a11c1e00..94df47158 100644 --- a/source/tests/test_runner.cpp +++ b/source/tests/test_runner.cpp @@ -23,8 +23,11 @@ int main(int argc, char *argv[]) { // Change the default outputter to a compiler error format outputter runner.setOutputter(new CppUnit::CompilerOutputter(&runner.result(), std::cerr)); - // Run the tests. - bool wasSucessful = runner.run(); + // Run the tests. An optional command-line argument selects a specific + // suite or test by path, e.g. "SocketTest" or + // "SocketTest::test_ip_ipv6_cursor_stripped". + std::string testPath = (argc > 1) ? argv[1] : ""; + bool wasSucessful = runner.run(testPath); // Return error code 1 if the one of test failed. return wasSucessful ? 0 : 1; From fa8b50732f2d2992f77ae1e4e40f26525a2e7a10 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 08:21:10 -0500 Subject: [PATCH 08/12] tests: run via ctest instead of automatically during make Replace the POST_BUILD auto-run with add_test() + enable_testing() so tests only run when explicitly requested. Build scripts gain a -t flag (Linux/macOS) and -run-tests switch (Windows) for local use. CI runs ctest as a dedicated step after each build on Linux (GCC), macOS, and FreeBSD. macOS non-bundle builds now enable tests by default. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/linux.yml | 5 +++++ .github/workflows/macos.yml | 3 +++ BUILD.md | 18 ++++++++++++------ CMakeLists.txt | 2 ++ mk/linux/build-mg.sh | 15 ++++++++++++++- mk/macos/build-mg.sh | 20 +++++++++++--------- mk/windoze/build-mg-vs-cmake.ps1 | 7 ++++++- source/tests/CMakeLists.txt | 21 ++++++++------------- 8 files changed, 61 insertions(+), 30 deletions(-) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 35739a05f..c57135f3d 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -58,6 +58,7 @@ jobs: git config --global --add safe.directory /home/runner/work/megaglest-source/megaglest-source cd mk/linux ./build-mg.sh -f + ctest --test-dir build --output-on-failure build-linux: strategy: @@ -91,6 +92,10 @@ jobs: mk/linux/build-mg.sh -m ${EXTRA_OPTS} make -C mk/linux/build -j$(nproc) VERBOSE=1 + - name: Run tests + if: ${{ matrix.release != true && matrix.compiler == 'gcc' }} + run: ctest --test-dir mk/linux/build --output-on-failure + - name: Build Release if: ${{ matrix.release == true }} run: | diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 15308ecd8..718228a68 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -33,6 +33,9 @@ jobs: mk/macos/build-mg.sh -m make -C mk/macos/build -j$(sysctl -n hw.ncpu) VERBOSE=1 + - name: Run tests + run: ctest --test-dir mk/macos/build --output-on-failure + - name: Git Hash if: ${{ github.ref == 'refs/heads/develop' }} run: | diff --git a/BUILD.md b/BUILD.md index 4e06bff63..99e55a567 100644 --- a/BUILD.md +++ b/BUILD.md @@ -99,20 +99,26 @@ cmake -LH ## Unit tests -Unit tests are built and run automatically as part of the normal build (enabled -by default in `build-mg.sh`). After a successful build the test binary is at: +Unit tests are built by default (enabled by `-DBUILD_MEGAGLEST_TESTS=ON` in +`build-mg.sh`). After a successful build the test binary is at: - mk/linux/megaglest_tests # Linux / macOS + mk/linux/megaglest_tests # Linux + mk/macos/megaglest_tests # macOS -You can run all tests manually: +Run all tests via CTest: - ./mk/linux/megaglest_tests + ctest --test-dir mk/linux/build --output-on-failure -To run only a specific test suite or a single test: +Or run the binary directly, optionally filtering by suite or test name: + ./mk/linux/megaglest_tests ./mk/linux/megaglest_tests SocketTest ./mk/linux/megaglest_tests SocketTest::test_ip_ipv6_cursor_stripped +To run tests as part of the build script: + + ./mk/linux/build-mg.sh -t # build then run tests + Tests live under `source/tests/`. To add tests for a new subsystem, create a subdirectory under `source/tests/shared_lib/` and add it to `DIRS_WITH_SRC` in `source/tests/CMakeLists.txt` — the glob will pick up any `.cpp` files there diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e0d402e5..d30477922 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,6 +10,8 @@ ENDIF() PROJECT( MegaGlest ) +enable_testing() + # This helps prevent certain errors such as what's mentioned in # https://github.com/MegaGlest/megaglest-source/pull/300 # and also some consider it good practice to specify a standard diff --git a/mk/linux/build-mg.sh b/mk/linux/build-mg.sh index 829468332..081a9dc12 100755 --- a/mk/linux/build-mg.sh +++ b/mk/linux/build-mg.sh @@ -28,9 +28,10 @@ LUA_FORCED_VERSION=0 FORCE_32BIT_CROSS_COMPILE=0 COMPILATION_WITHOUT=0 BUILD_MEGAGLEST_TESTS="ON" +RUN_TESTS=0 SHOW_CMAKE_OPTIONS=0 -while getopts "B:c:defg:hl:mnopswx" option; do +while getopts "B:c:defg:hl:mnopswxt" option; do case "${option}" in B) BUILD_DIR=${OPTARG} @@ -72,6 +73,7 @@ while getopts "B:c:defg:hl:mnopswx" option; do echo " -w : Force compilation 'Without using wxWidgets'" echo " -x : Force cross compiling on x64 linux to produce an x86 32 bit binary" + echo " -t : Run unit tests after build (requires -DBUILD_MEGAGLEST_TESTS=ON)" echo " -o : Show available cmake options" echo " -h : Display this help usage" echo " -- : Pass remaining arguments verbatim to cmake" @@ -111,6 +113,9 @@ while getopts "B:c:defg:hl:mnopswx" option; do FORCE_32BIT_CROSS_COMPILE=1 # echo "${option} value: ${OPTARG}" ;; + t) + RUN_TESTS=1 + ;; \?) echo "Script Invalid option: -$OPTARG" >&2 @@ -376,6 +381,14 @@ else echo 'ERROR: MAKE failed.' >&2; exit 2 fi + if [ $RUN_TESTS = 1 ]; then + echo "==================> Running unit tests... <==================================" + ctest --output-on-failure + if [ $? -ne 0 ]; then + echo 'ERROR: Tests failed.' >&2; exit 3 + fi + fi + cd .. echo '' echo 'BUILD COMPLETE.' diff --git a/mk/macos/build-mg.sh b/mk/macos/build-mg.sh index 4f0fb1831..0b68c8103 100755 --- a/mk/macos/build-mg.sh +++ b/mk/macos/build-mg.sh @@ -21,6 +21,7 @@ WANT_STATIC_LIBS="-DWANT_STATIC_LIBS=ON" FORCE_EMBEDDED_LIBS=0 LUA_FORCED_VERSION=0 COMPILATION_WITHOUT=0 +RUN_TESTS=0 SHOW_CMAKE_OPTIONS=0 # Some brew things don't appear to link correctly by themselves. @@ -54,7 +55,7 @@ then fi fi -while getopts "B:c:defhl:mnopwxb" option; do +while getopts "B:c:defhl:mnoptwxb" option; do case "${option}" in B) BUILD_DIR=${OPTARG};; c) CPU_COUNT=${OPTARG};; @@ -73,6 +74,7 @@ while getopts "B:c:defhl:mnopwxb" option; do echo " -l x : Force using LUA version x - example: -l 5.3" echo " -m : Force running CMAKE only to create Make files (do not compile)" echo " -n : Force running MAKE only to compile (assume CMAKE already built make files)" + echo " -t : Run unit tests after build (requires -DBUILD_MEGAGLEST_TESTS=ON)" echo " -w : Force compilation 'Without using wxWidgets'" echo " -x : Force usage of Xcode and xcodebuild" echo " -o : Show available cmake options" @@ -90,6 +92,7 @@ while getopts "B:c:defhl:mnopwxb" option; do fi SHOW_CMAKE_OPTIONS=1 CMAKE_ONLY=1;; + t) RUN_TESTS=1;; w) COMPILATION_WITHOUT=1;; x) USE_XCODE=1;; b) BUILD_BUNDLE=1 @@ -237,14 +240,7 @@ fi if [ "$MAKE_ONLY" -eq "0" ]; then EXTRA_CMAKE_OPTIONS="${EXTRA_CMAKE_OPTIONS} -DWANT_DEV_OUTPATH=ON $WANT_STATIC_LIBS -DBREAKPAD_ROOT=$BREAKPAD_ROOT" if [ "$BUILD_BUNDLE" -ne "1" ]; then - EXTRA_CMAKE_OPTIONS="${EXTRA_CMAKE_OPTIONS} -DCMAKE_INSTALL_PREFIX=''" - if [ "$GCC_FORCED" -ne "1" ] || [ "$USE_XCODE" -eq "1" ]; then : - #^ Remove this condition when it V will start working on gcc - #EXTRA_CMAKE_OPTIONS="${EXTRA_CMAKE_OPTIONS} -DBUILD_MEGAGLEST_TESTS=ON" - #^ Uncomment when it will start working on clang - else - rm -f ../megaglest_tests - fi + EXTRA_CMAKE_OPTIONS="${EXTRA_CMAKE_OPTIONS} -DCMAKE_INSTALL_PREFIX='' -DBUILD_MEGAGLEST_TESTS=ON" rm -f ../MegaGlest*.dmg else EXTRA_CMAKE_OPTIONS="${EXTRA_CMAKE_OPTIONS} -DCPACK_GENERATOR=Bundle -DWANT_SINGLE_INSTALL_DIRECTORY=ON" @@ -276,6 +272,12 @@ else echo "==================> About to call make with $NUMCORES cores... <==================" make -j$NUMCORES if [ "$?" -ne "0" ]; then echo 'ERROR: MAKE failed.' >&2; exit 2; fi + + if [ "$RUN_TESTS" -eq "1" ]; then + echo "==================> Running unit tests... <==================================" + ctest --test-dir build --output-on-failure + if [ "$?" -ne "0" ]; then echo 'ERROR: Tests failed.' >&2; exit 3; fi + fi fi if [ -d "../Debug" ]; then mv -f ../Debug/megaglest* "$SCRIPTDIR"; rm -rf ../Debug diff --git a/mk/windoze/build-mg-vs-cmake.ps1 b/mk/windoze/build-mg-vs-cmake.ps1 index 2554a154c..b4bbb3a70 100644 --- a/mk/windoze/build-mg-vs-cmake.ps1 +++ b/mk/windoze/build-mg-vs-cmake.ps1 @@ -1,6 +1,6 @@ # Build MegaGlest on Windows. # Author: James Sherratt. -param(${vcpkg-location}, ${buildtype}, [string[]]${cmake-options}, [switch]${show-options}) +param(${vcpkg-location}, ${buildtype}, [string[]]${cmake-options}, [switch]${show-options}, [switch]${run-tests}) $sword = [char]::ConvertFromUtf32(0x2694) Write-Output "=====$sword MegaGlest $sword=====" @@ -145,6 +145,11 @@ cmake --build "$buildFolder" --config $buildtype --target ALL_BUILD if ($?) { "Build succeeded. megaglest.exe, megaglest_editor.exe and megaglest_g3dviewer.exe can be found in mk/windoze/." + if (${run-tests}) { + Write-Title "Running unit tests" + ctest --test-dir "$buildFolder" --build-config $buildtype --output-on-failure + if (!$?) { "Tests failed."; Exit 3 } + } } else { "Build failed. Please make sure you have installed VS C++ tools (2019 or 2022): https://visualstudio.microsoft.com/downloads ." diff --git a/source/tests/CMakeLists.txt b/source/tests/CMakeLists.txt index 82c89f8ba..a9f961075 100644 --- a/source/tests/CMakeLists.txt +++ b/source/tests/CMakeLists.txt @@ -206,19 +206,14 @@ IF(BUILD_MEGAGLEST_TESTS) ENDIF() TARGET_LINK_LIBRARIES(${TARGET_NAME} ${EXTERNAL_LIBS}) - IF(NOT "${CMAKE_GENERATOR}" STREQUAL "Xcode") - # Run the unit tests after build - IF(EXISTS ${XVFB_EXEC}) - MESSAGE("***-- Found xvfb-run: ${XVFB_EXEC} will run tests with it.") - - add_custom_command(TARGET ${TARGET_NAME} POST_BUILD - COMMAND ${XVFB_EXEC} --auto-servernum --server-num=770 ${EXECUTABLE_OUTPUT_PATH}${TARGET_NAME} - COMMENT "***-- Found MegaGlest test runner: ${TARGET_NAME} about to run unit tests via xvfb...") - ELSE() - add_custom_command(TARGET ${TARGET_NAME} POST_BUILD - COMMAND ${EXECUTABLE_OUTPUT_PATH}${TARGET_NAME} - COMMENT "***-- Found MegaGlest test runner: ${TARGET_NAME} about to run unit tests...") - ENDIF() + # Register with CTest so tests run via `ctest` / `cmake --build . --target test` + IF(EXISTS ${XVFB_EXEC}) + MESSAGE("***-- Found xvfb-run: ${XVFB_EXEC} will run tests with it.") + add_test(NAME megaglest_tests + COMMAND ${XVFB_EXEC} --auto-servernum --server-num=770 ${EXECUTABLE_OUTPUT_PATH}${TARGET_NAME}) + ELSE() + add_test(NAME megaglest_tests + COMMAND ${EXECUTABLE_OUTPUT_PATH}${TARGET_NAME}) ENDIF() ENDIF() From ca3b1a673e3307e3bfacd4a02edaa71cea6bfa11 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 08:24:32 -0500 Subject: [PATCH 09/12] tests: fix ctest path in build scripts The scripts cd into build/ before running make, so --test-dir build resolved to build/build/. Drop --test-dir and let ctest run in the current directory instead. Co-Authored-By: Claude Sonnet 4.6 --- mk/macos/build-mg.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mk/macos/build-mg.sh b/mk/macos/build-mg.sh index 0b68c8103..1ad624511 100755 --- a/mk/macos/build-mg.sh +++ b/mk/macos/build-mg.sh @@ -275,7 +275,7 @@ else if [ "$RUN_TESTS" -eq "1" ]; then echo "==================> Running unit tests... <==================================" - ctest --test-dir build --output-on-failure + ctest --output-on-failure if [ "$?" -ne "0" ]; then echo 'ERROR: Tests failed.' >&2; exit 3; fi fi fi From d173455d96cfdba6a3b2c7a6a08dc2c9dde793d0 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Tue, 24 Mar 2026 19:12:44 -0500 Subject: [PATCH 10/12] test: Add CppUnit network tests for dual-stack connect/accept Five tests in NetworkTest verify the dual-stack server implementation: - server binds and reports isPortBound() - IPv4 client connects and is accepted - accepted socket reports a plain IPv4 address (not ::ffff:...) - IPv6 client connects and is accepted (skipped if no IPv6 dual-stack) - accepted socket reports the IPv6 client address (skipped likewise) Also adds Socket::getSocketFamily() to query the address family in use, used by the tests to detect whether dual-stack is available. Co-Authored-By: Claude Sonnet 4.6 --- .../include/platform/posix/socket.h | 1 + .../shared_lib/platform/network_test.cpp | 132 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 source/tests/shared_lib/platform/network_test.cpp diff --git a/source/shared_lib/include/platform/posix/socket.h b/source/shared_lib/include/platform/posix/socket.h index 624431efd..97ac6ca25 100644 --- a/source/shared_lib/include/platform/posix/socket.h +++ b/source/shared_lib/include/platform/posix/socket.h @@ -204,6 +204,7 @@ class Socket { virtual std::string getIpAddress(); virtual void setIpAddress(std::string value) { ipAddress = value; } + int getSocketFamily() const { return socketFamily; } uint32 getConnectedIPAddress(string IP = ""); diff --git a/source/tests/shared_lib/platform/network_test.cpp b/source/tests/shared_lib/platform/network_test.cpp new file mode 100644 index 000000000..8137eb6fd --- /dev/null +++ b/source/tests/shared_lib/platform/network_test.cpp @@ -0,0 +1,132 @@ +// ============================================================== +// This file is part of MegaGlest Unit Tests (www.megaglest.org) +// +// You can redistribute this code and/or modify it under +// the terms of the GNU General Public License as published +// by the Free Software Foundation; either version 2 of the +// License, or (at your option) any later version +// ============================================================== + +#include +#include "socket.h" +#include + +using namespace Shared::Platform; + +// Base port for these tests; chosen to avoid well-known services. +static const int TEST_PORT_BASE = 34527; + +// Poll server.accept() for up to ~100 ms (50 × 2 ms sleeps). +static Socket *pollAccept(ServerSocket &server, int tries = 50) { + for (int i = 0; i < tries; ++i) { + Socket *s = server.accept(false); + if (s != nullptr) return s; + usleep(2000); + } + return nullptr; +} + +// Returns true if this platform supports IPv6 dual-stack: an AF_INET6 socket +// can be created and IPV6_V6ONLY can be cleared. ServerSocket::bind() already +// does this probe and falls back to IPv4 silently, so we just check which +// family was actually chosen. +static bool platformHasIPv6DualStack() { + ServerSocket probe; + probe.bind(TEST_PORT_BASE + 9); + return probe.getSocketFamily() == AF_INET6; +} + +class NetworkTest : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(NetworkTest); + CPPUNIT_TEST(test_server_bind); + CPPUNIT_TEST(test_ipv4_client_connect); + CPPUNIT_TEST(test_ipv4_client_address_on_accept); + CPPUNIT_TEST(test_ipv6_client_connect); + CPPUNIT_TEST(test_ipv6_client_address_on_accept); + CPPUNIT_TEST_SUITE_END(); + + public: + // Server binds on a port and reports the port as bound + void test_server_bind() { + ServerSocket server; + server.bind(TEST_PORT_BASE); + server.listen(1); + CPPUNIT_ASSERT(server.isPortBound()); + CPPUNIT_ASSERT(server.isSocketValid()); + } + + // An IPv4 client can connect to the listening server + void test_ipv4_client_connect() { + ServerSocket server; + server.bind(TEST_PORT_BASE + 1); + server.listen(1); + + ClientSocket client; + client.connect(Ip("127.0.0.1"), TEST_PORT_BASE + 1); + + Socket *accepted = pollAccept(server); + CPPUNIT_ASSERT_MESSAGE("IPv4 connection was not accepted", accepted != nullptr); + CPPUNIT_ASSERT(accepted->isSocketValid()); + delete accepted; + } + + // When an IPv4 client connects to a dual-stack server the accepted socket's + // IP is a plain IPv4 string, not the mapped form "::ffff:127.0.0.1" + void test_ipv4_client_address_on_accept() { + ServerSocket server; + server.bind(TEST_PORT_BASE + 2); + server.listen(1); + + ClientSocket client; + client.connect(Ip("127.0.0.1"), TEST_PORT_BASE + 2); + + Socket *accepted = pollAccept(server); + CPPUNIT_ASSERT_MESSAGE("IPv4 connection was not accepted", accepted != nullptr); + CPPUNIT_ASSERT_EQUAL(std::string("127.0.0.1"), accepted->getIpAddress()); + delete accepted; + } + + // An IPv6 client can connect to the dual-stack server. + // Skipped on platforms without IPv6 support. + void test_ipv6_client_connect() { + if (!platformHasIPv6DualStack()) { + std::cout << " [skipped: no IPv6 dual-stack] "; + return; + } + + ServerSocket server; + server.bind(TEST_PORT_BASE + 3); + server.listen(1); + + ClientSocket client; + client.connect(Ip("::1"), TEST_PORT_BASE + 3); + + Socket *accepted = pollAccept(server); + CPPUNIT_ASSERT_MESSAGE("IPv6 connection was not accepted", accepted != nullptr); + CPPUNIT_ASSERT(accepted->isSocketValid()); + delete accepted; + } + + // When an IPv6 client connects, the accepted socket reports the IPv6 + // address of the client + void test_ipv6_client_address_on_accept() { + if (!platformHasIPv6DualStack()) { + std::cout << " [skipped: no IPv6 dual-stack] "; + return; + } + + ServerSocket server; + server.bind(TEST_PORT_BASE + 4); + server.listen(1); + + ClientSocket client; + client.connect(Ip("::1"), TEST_PORT_BASE + 4); + + Socket *accepted = pollAccept(server); + CPPUNIT_ASSERT_MESSAGE("IPv6 connection was not accepted", accepted != nullptr); + CPPUNIT_ASSERT_EQUAL(std::string("::1"), accepted->getIpAddress()); + delete accepted; + } +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(NetworkTest); From 05af5fab5fa3cf8fc5f15f91d085a5ec2d1ca033 Mon Sep 17 00:00:00 2001 From: andy5995 Date: Fri, 1 May 2026 16:38:26 -0500 Subject: [PATCH 11/12] Fix client parsing ipv6 address, sdl label and other issue --- source/glest_game/main/main.cpp | 7 +- .../glest_game/menu/menu_state_join_game.cpp | 126 +++----------- source/glest_game/menu/menu_state_join_game.h | 1 + .../include/platform/posix/socket.h | 11 ++ .../sources/platform/posix/socket.cpp | 28 +++- .../tests/shared_lib/platform/socket_test.cpp | 157 ++++++++++++++++++ 6 files changed, 221 insertions(+), 109 deletions(-) diff --git a/source/glest_game/main/main.cpp b/source/glest_game/main/main.cpp index c609ae0dc..fbd0fecab 100644 --- a/source/glest_game/main/main.cpp +++ b/source/glest_game/main/main.cpp @@ -5321,12 +5321,7 @@ int glestMain(int argc, char **argv) { string autoConnectServer = paramPartTokens[1]; int port = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); - vector paramPartTokens2; - Tokenize(autoConnectServer, paramPartTokens2, ":"); - autoConnectServer = paramPartTokens2[0]; - if (paramPartTokens2.size() >= 2 && paramPartTokens2[1].length() > 0) { - port = strToInt(paramPartTokens2[1]); - } + Ip::parseHostPort(autoConnectServer, port); printf("Connecting to host [%s] using port: %d\n", autoConnectServer.c_str(), port); if (autoConnectServer == "auto-connect") { diff --git a/source/glest_game/menu/menu_state_join_game.cpp b/source/glest_game/menu/menu_state_join_game.cpp index 5e5ab1780..a258cdfeb 100644 --- a/source/glest_game/menu/menu_state_join_game.cpp +++ b/source/glest_game/menu/menu_state_join_game.cpp @@ -70,6 +70,7 @@ void MenuStateJoinGame::CommonInit(bool connect, Ip serverIp, int portNumberOver containerName = "JoinGame"; abortAutoFind = false; autoConnectToServer = false; + serverPortOverride = portNumberOverride; Lang &lang = Lang::getInstance(); Config &config = Config::getInstance(); NetworkManager &networkManager = NetworkManager::getInstance(); @@ -165,13 +166,7 @@ void MenuStateJoinGame::CommonInit(bool connect, Ip serverIp, int portNumberOver string host = labelServerIp.getText(); int portNumber = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); - std::vector hostPartsList; - Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() > 1) { - host = hostPartsList[0]; - replaceAll(hostPartsList[1], "_", ""); - portNumber = strToInt(hostPartsList[1]); - } + Ip::parseHostPort(host, portNumber); string port = " (" + intToStr(portNumber) + ")"; labelServerPort.setText(port); @@ -187,52 +182,18 @@ void MenuStateJoinGame::CommonInit(bool connect, Ip serverIp, int portNumberOver connected = false; playerIndex = -1; - // server ip + // server ip — label stores only the address; port is tracked via serverPortOverride if (connect == true) { - string hostIP = serverIp.getString(); - if (portNumberOverride > 0) { - hostIP += ":" + intToStr(portNumberOverride); - } - - labelServerIp.setText(hostIP + "_"); - + labelServerIp.setText(serverIp.getString() + "_"); autoConnectToServer = true; } else { - string hostIP = config.getString("ServerIp"); - if (portNumberOverride > 0) { - hostIP += ":" + intToStr(portNumberOverride); - } - - labelServerIp.setText(hostIP + "_"); + labelServerIp.setText(config.getString("ServerIp") + "_"); } host = labelServerIp.getText(); - portNumber = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); - // Strip trailing '_' cursor character before parsing - replaceAll(host, "_", ""); - // Parse host:port, being careful not to split on colons inside IPv6 addresses. - // Bracket notation [addr]:port is the standard for IPv6 with a port. - // IPv4 addresses always contain dots; IPv6 hex groups never do — use that - // to distinguish "192.168.1.1:61357:61357" (IPv4 + duplicate port suffix) - // from "2001:db8::1" (IPv6). - if (!host.empty() && host[0] == '[') { - size_t close = host.find(']'); - if (close != string::npos) { - if (close + 1 < host.size() && host[close + 1] == ':') { - portNumber = strToInt(host.substr(close + 2)); - } - host = host.substr(1, close - 1); - } - } else { - hostPartsList.clear(); - Tokenize(host, hostPartsList, ":"); - bool isIPv4OrHostname = (hostPartsList.size() == 2) || (hostPartsList.size() > 2 && hostPartsList[0].find('.') != string::npos); - if (hostPartsList.size() >= 2 && isIPv4OrHostname) { - host = hostPartsList[0]; - portNumber = strToInt(hostPartsList[1]); - } - // Multiple colons with no dots in first segment → IPv6, leave host unchanged - } + portNumber = serverPortOverride > 0 ? serverPortOverride + : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + Ip::parseHostPort(host, portNumber); port = " (" + intToStr(portNumber) + ")"; labelServerPort.setText(port); @@ -265,14 +226,9 @@ void MenuStateJoinGame::reloadUI() { labelServerPortLabel.setText(lang.getString("ServerPort")); string host = labelServerIp.getText(); - int portNumber = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); - std::vector hostPartsList; - Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() > 1) { - host = hostPartsList[0]; - replaceAll(hostPartsList[1], "_", ""); - portNumber = strToInt(hostPartsList[1]); - } + int portNumber = serverPortOverride > 0 ? serverPortOverride + : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + Ip::parseHostPort(host, portNumber); string port = " (" + intToStr(portNumber) + ")"; labelServerPort.setText(port); @@ -388,14 +344,9 @@ void MenuStateJoinGame::mouseClick(int x, int y, MouseButton mouseButton) { string host = labelServerIp.getText(); Config &config = Config::getInstance(); - int portNumber = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); - std::vector hostPartsList; - Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() > 1) { - host = hostPartsList[0]; - replaceAll(hostPartsList[1], "_", ""); - portNumber = strToInt(hostPartsList[1]); - } + int portNumber = serverPortOverride > 0 ? serverPortOverride + : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + Ip::parseHostPort(host, portNumber); string port = " (" + intToStr(portNumber) + ")"; labelServerPort.setText(port); @@ -603,16 +554,11 @@ void MenuStateJoinGame::update() { labelInfo.setText(lang.getString("WaitingHost")); string host = labelServerIp.getText(); - std::vector hostPartsList; - Tokenize(host, hostPartsList, ":"); - if (hostPartsList.size() > 1) { - host = hostPartsList[0]; - replaceAll(hostPartsList[1], "_", ""); - } - string saveHost = Ip(host).getString(); - if (hostPartsList.size() > 1) { - saveHost += ":" + hostPartsList[1]; - } + Config &config = Config::getInstance(); + int portNumber = serverPortOverride > 0 ? serverPortOverride + : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + Ip::parseHostPort(host, portNumber); + string saveHost = Ip::buildHostDisplay(Ip(host).getString(), portNumber); servers.setString(clientInterface->getServerName(), saveHost); } @@ -771,32 +717,11 @@ bool MenuStateJoinGame::connectToServer() { Config &config = Config::getInstance(); string host = labelServerIp.getText(); - int port = config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); - std::vector hostPartsList; - // Strip trailing '_' cursor character before parsing - replaceAll(host, "_", ""); - // Parse host:port, being careful not to split on colons inside IPv6 addresses. - // Bracket notation [addr]:port is the standard for IPv6 with a port. - // A bare IPv6 address (more than one colon) has no port component. - if (!host.empty() && host[0] == '[') { - size_t close = host.find(']'); - if (close != string::npos) { - if (close + 1 < host.size() && host[close + 1] == ':') { - port = strToInt(host.substr(close + 2)); - } - host = host.substr(1, close - 1); - } - } else { - Tokenize(host, hostPartsList, ":"); - bool isIPv4OrHostname = (hostPartsList.size() == 2) || (hostPartsList.size() > 2 && hostPartsList[0].find('.') != string::npos); - if (hostPartsList.size() >= 2 && isIPv4OrHostname) { - host = hostPartsList[0]; - port = strToInt(hostPartsList[1]); - } - // Multiple colons with no dots in first segment → IPv6, leave host unchanged - } + int port = serverPortOverride > 0 ? serverPortOverride + : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + Ip::parseHostPort(host, port); + serverPortOverride = port; Ip serverIp(host); - ClientInterface *clientInterface = NetworkManager::getInstance().getClientInterface(); clientInterface->connect(serverIp, port); @@ -822,10 +747,7 @@ bool MenuStateJoinGame::connectToServer() { } } if (clientInterface->isConnected() == true && clientInterface->getIntroDone() == true) { - string saveHost = Ip(host).getString(); - if (hostPartsList.size() > 1) { - saveHost += ":" + hostPartsList[1]; - } + string saveHost = Ip::buildHostDisplay(Ip(host).getString(), port); servers.setString(clientInterface->getServerName(), saveHost); servers.save(serversSavedFile); diff --git a/source/glest_game/menu/menu_state_join_game.h b/source/glest_game/menu/menu_state_join_game.h index 7e45d5571..2c5e629fb 100644 --- a/source/glest_game/menu/menu_state_join_game.h +++ b/source/glest_game/menu/menu_state_join_game.h @@ -68,6 +68,7 @@ class MenuStateJoinGame : public MenuState, public DiscoveredServersInterface { string serversSavedFile; bool abortAutoFind; bool autoConnectToServer; + int serverPortOverride; public: MenuStateJoinGame(Program *program, MainMenu *mainMenu, bool connect = false, Ip serverIp = Ip(), int portNumberOverride = -1); diff --git a/source/shared_lib/include/platform/posix/socket.h b/source/shared_lib/include/platform/posix/socket.h index 97ac6ca25..50ffbf6e4 100644 --- a/source/shared_lib/include/platform/posix/socket.h +++ b/source/shared_lib/include/platform/posix/socket.h @@ -107,6 +107,17 @@ class Ip { unsigned char getByte(int byteIndex) { return bytes[byteIndex]; } string getString() const; + + // Parse "host", "host:port", or "[ipv6]:port" from a UI label string. + // Strips trailing '_' cursor characters. Updates host and port in-place. + // Bare IPv6 addresses (multiple colons, no brackets) leave host unchanged + // and do not modify port. + static void parseHostPort(std::string &host, int &port); + + // Format host:port for display. Uses "[host]:port" bracket notation when + // host is an IPv6 address (contains a colon). Returns host unchanged when + // port <= 0. + static std::string buildHostDisplay(const std::string &host, int port); }; // ===================================================== diff --git a/source/shared_lib/sources/platform/posix/socket.cpp b/source/shared_lib/sources/platform/posix/socket.cpp index 925ee1c8a..309f55419 100644 --- a/source/shared_lib/sources/platform/posix/socket.cpp +++ b/source/shared_lib/sources/platform/posix/socket.cpp @@ -316,6 +316,33 @@ string Ip::getString() const { return intToStr(bytes[0]) + "." + intToStr(bytes[1]) + "." + intToStr(bytes[2]) + "." + intToStr(bytes[3]); } +void Ip::parseHostPort(string &host, int &port) { + replaceAll(host, "_", ""); + if (!host.empty() && host[0] == '[') { + size_t close = host.find(']'); + if (close != string::npos) { + if (close + 2 <= host.size() && host[close + 1] == ':') { + port = strToInt(host.substr(close + 2)); + } + host = host.substr(1, close - 1); + } + } else { + size_t firstColon = host.find(':'); + if (firstColon != string::npos && host.find(':', firstColon + 1) == string::npos) { + port = strToInt(host.substr(firstColon + 1)); + host = host.substr(0, firstColon); + } + } +} + +string Ip::buildHostDisplay(const string &host, int port) { + if (port <= 0) return host; + if (host.find(':') != string::npos) { + return "[" + host + "]:" + intToStr(port); + } + return host + ":" + intToStr(port); +} + // =============================================== // class Socket // =============================================== @@ -2326,7 +2353,6 @@ void ClientSocket::connect(const Ip &ip, int port) { disconnectSocket(); return; } - // If the resolved family differs from the current socket family, recreate // the socket so it matches (e.g. upgrade AF_INET to AF_INET6). if (res->ai_family != socketFamily) { diff --git a/source/tests/shared_lib/platform/socket_test.cpp b/source/tests/shared_lib/platform/socket_test.cpp index 908fdc277..edf9ed339 100644 --- a/source/tests/shared_lib/platform/socket_test.cpp +++ b/source/tests/shared_lib/platform/socket_test.cpp @@ -22,6 +22,23 @@ class SocketTest : public CppUnit::TestFixture { CPPUNIT_TEST(test_ip_ipv6_cursor_stripped); CPPUNIT_TEST(test_ip_empty_string); + CPPUNIT_TEST(test_parseHostPort_ipv4_with_port); + CPPUNIT_TEST(test_parseHostPort_ipv4_no_port); + CPPUNIT_TEST(test_parseHostPort_hostname_with_port); + CPPUNIT_TEST(test_parseHostPort_ipv6_bracket_with_port); + CPPUNIT_TEST(test_parseHostPort_ipv6_bracket_no_port); + CPPUNIT_TEST(test_parseHostPort_ipv6_bare_no_port_extracted); + CPPUNIT_TEST(test_parseHostPort_cursor_stripped); + CPPUNIT_TEST(test_parseHostPort_ipv6_bracket_cursor_stripped); + + CPPUNIT_TEST(test_buildHostDisplay_ipv4_with_port); + CPPUNIT_TEST(test_buildHostDisplay_ipv4_no_port); + CPPUNIT_TEST(test_buildHostDisplay_ipv6_with_port); + CPPUNIT_TEST(test_buildHostDisplay_ipv6_no_port); + CPPUNIT_TEST(test_buildHostDisplay_hostname_with_port); + CPPUNIT_TEST(test_buildHostDisplay_roundtrip_ipv6); + CPPUNIT_TEST(test_buildHostDisplay_roundtrip_ipv4); + CPPUNIT_TEST_SUITE_END(); public: @@ -60,6 +77,146 @@ class SocketTest : public CppUnit::TestFixture { Ip ip(""); CPPUNIT_ASSERT_EQUAL(std::string("0.0.0.0"), ip.getString()); } + + // --- parseHostPort --- + + // IPv4 address with port: extracts both + void test_parseHostPort_ipv4_with_port() { + std::string host = "192.168.1.1:6234"; + int port = 0; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("192.168.1.1"), host); + CPPUNIT_ASSERT_EQUAL(6234, port); + } + + // IPv4 address without port: host unchanged, port unchanged + void test_parseHostPort_ipv4_no_port() { + std::string host = "192.168.1.1"; + int port = 9999; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("192.168.1.1"), host); + CPPUNIT_ASSERT_EQUAL(9999, port); + } + + // Hostname with port + void test_parseHostPort_hostname_with_port() { + std::string host = "example.com:61367"; + int port = 0; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("example.com"), host); + CPPUNIT_ASSERT_EQUAL(61367, port); + } + + // IPv6 in bracket notation with port: the crash case + void test_parseHostPort_ipv6_bracket_with_port() { + std::string host = "[2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b]:61367"; + int port = 0; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b"), host); + CPPUNIT_ASSERT_EQUAL(61367, port); + } + + // IPv6 in bracket notation without port + void test_parseHostPort_ipv6_bracket_no_port() { + std::string host = "[::1]"; + int port = 9999; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("::1"), host); + CPPUNIT_ASSERT_EQUAL(9999, port); + } + + // Bare IPv6 (no brackets): host is left intact, port is not changed. + // This is the exact input that caused the strToInt crash on "1c43". + void test_parseHostPort_ipv6_bare_no_port_extracted() { + std::string host = "2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b"; + int port = 9999; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b"), host); + CPPUNIT_ASSERT_EQUAL(9999, port); + } + + // UI cursor '_' is stripped before parsing + void test_parseHostPort_cursor_stripped() { + std::string host = "192.168.0.5:6234_"; + int port = 0; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("192.168.0.5"), host); + CPPUNIT_ASSERT_EQUAL(6234, port); + } + + // UI cursor stripped from bracketed IPv6 label text + void test_parseHostPort_ipv6_bracket_cursor_stripped() { + std::string host = "[::1]:61367_"; + int port = 0; + Ip::parseHostPort(host, port); + CPPUNIT_ASSERT_EQUAL(std::string("::1"), host); + CPPUNIT_ASSERT_EQUAL(61367, port); + } + + // --- buildHostDisplay --- + + // IPv4 with positive port: plain "host:port" + void test_buildHostDisplay_ipv4_with_port() { + CPPUNIT_ASSERT_EQUAL( + std::string("192.168.1.1:6234"), + Ip::buildHostDisplay("192.168.1.1", 6234)); + } + + // IPv4 with port <= 0: host returned as-is + void test_buildHostDisplay_ipv4_no_port() { + CPPUNIT_ASSERT_EQUAL( + std::string("192.168.1.1"), + Ip::buildHostDisplay("192.168.1.1", 0)); + } + + // IPv6 with port: bracket notation "[addr]:port" + void test_buildHostDisplay_ipv6_with_port() { + CPPUNIT_ASSERT_EQUAL( + std::string("[2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b]:61367"), + Ip::buildHostDisplay("2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b", 61367)); + } + + // IPv6 with port <= 0: host returned as-is (no brackets added) + void test_buildHostDisplay_ipv6_no_port() { + CPPUNIT_ASSERT_EQUAL( + std::string("::1"), + Ip::buildHostDisplay("::1", 0)); + } + + // Hostname with port: plain "host:port" + void test_buildHostDisplay_hostname_with_port() { + CPPUNIT_ASSERT_EQUAL( + std::string("example.com:6234"), + Ip::buildHostDisplay("example.com", 6234)); + } + + // buildHostDisplay output fed back into parseHostPort recovers the same + // host and port — this is the round-trip that connectToServer() relies on + void test_buildHostDisplay_roundtrip_ipv6() { + const std::string addr = "2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b"; + const int origPort = 61367; + std::string display = Ip::buildHostDisplay(addr, origPort) + '_'; + + std::string host = display; + int port = 9999; + Ip::parseHostPort(host, port); + + CPPUNIT_ASSERT_EQUAL(addr, host); + CPPUNIT_ASSERT_EQUAL(origPort, port); + } + + void test_buildHostDisplay_roundtrip_ipv4() { + const std::string addr = "192.168.1.1"; + const int origPort = 6234; + std::string display = Ip::buildHostDisplay(addr, origPort) + '_'; + + std::string host = display; + int port = 9999; + Ip::parseHostPort(host, port); + + CPPUNIT_ASSERT_EQUAL(addr, host); + CPPUNIT_ASSERT_EQUAL(origPort, port); + } }; CPPUNIT_TEST_SUITE_REGISTRATION(SocketTest); From 724d69b79b1a8a852c71476e60e29bdcd3206938 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 21:38:57 +0000 Subject: [PATCH 12/12] style: Apply clang-format --- .../glest_game/menu/menu_state_join_game.cpp | 15 ++++------ .../tests/shared_lib/platform/socket_test.cpp | 28 ++++--------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/source/glest_game/menu/menu_state_join_game.cpp b/source/glest_game/menu/menu_state_join_game.cpp index a258cdfeb..14db0a3ca 100644 --- a/source/glest_game/menu/menu_state_join_game.cpp +++ b/source/glest_game/menu/menu_state_join_game.cpp @@ -191,8 +191,7 @@ void MenuStateJoinGame::CommonInit(bool connect, Ip serverIp, int portNumberOver } host = labelServerIp.getText(); - portNumber = serverPortOverride > 0 ? serverPortOverride - : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + portNumber = serverPortOverride > 0 ? serverPortOverride : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); Ip::parseHostPort(host, portNumber); port = " (" + intToStr(portNumber) + ")"; @@ -226,8 +225,7 @@ void MenuStateJoinGame::reloadUI() { labelServerPortLabel.setText(lang.getString("ServerPort")); string host = labelServerIp.getText(); - int portNumber = serverPortOverride > 0 ? serverPortOverride - : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + int portNumber = serverPortOverride > 0 ? serverPortOverride : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); Ip::parseHostPort(host, portNumber); string port = " (" + intToStr(portNumber) + ")"; @@ -344,8 +342,7 @@ void MenuStateJoinGame::mouseClick(int x, int y, MouseButton mouseButton) { string host = labelServerIp.getText(); Config &config = Config::getInstance(); - int portNumber = serverPortOverride > 0 ? serverPortOverride - : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + int portNumber = serverPortOverride > 0 ? serverPortOverride : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); Ip::parseHostPort(host, portNumber); string port = " (" + intToStr(portNumber) + ")"; @@ -555,8 +552,7 @@ void MenuStateJoinGame::update() { string host = labelServerIp.getText(); Config &config = Config::getInstance(); - int portNumber = serverPortOverride > 0 ? serverPortOverride - : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + int portNumber = serverPortOverride > 0 ? serverPortOverride : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); Ip::parseHostPort(host, portNumber); string saveHost = Ip::buildHostDisplay(Ip(host).getString(), portNumber); @@ -717,8 +713,7 @@ bool MenuStateJoinGame::connectToServer() { Config &config = Config::getInstance(); string host = labelServerIp.getText(); - int port = serverPortOverride > 0 ? serverPortOverride - : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); + int port = serverPortOverride > 0 ? serverPortOverride : config.getInt("PortServer", intToStr(GameConstants::serverPort).c_str()); Ip::parseHostPort(host, port); serverPortOverride = port; Ip serverIp(host); diff --git a/source/tests/shared_lib/platform/socket_test.cpp b/source/tests/shared_lib/platform/socket_test.cpp index edf9ed339..0de0002ba 100644 --- a/source/tests/shared_lib/platform/socket_test.cpp +++ b/source/tests/shared_lib/platform/socket_test.cpp @@ -156,39 +156,21 @@ class SocketTest : public CppUnit::TestFixture { // --- buildHostDisplay --- // IPv4 with positive port: plain "host:port" - void test_buildHostDisplay_ipv4_with_port() { - CPPUNIT_ASSERT_EQUAL( - std::string("192.168.1.1:6234"), - Ip::buildHostDisplay("192.168.1.1", 6234)); - } + void test_buildHostDisplay_ipv4_with_port() { CPPUNIT_ASSERT_EQUAL(std::string("192.168.1.1:6234"), Ip::buildHostDisplay("192.168.1.1", 6234)); } // IPv4 with port <= 0: host returned as-is - void test_buildHostDisplay_ipv4_no_port() { - CPPUNIT_ASSERT_EQUAL( - std::string("192.168.1.1"), - Ip::buildHostDisplay("192.168.1.1", 0)); - } + void test_buildHostDisplay_ipv4_no_port() { CPPUNIT_ASSERT_EQUAL(std::string("192.168.1.1"), Ip::buildHostDisplay("192.168.1.1", 0)); } // IPv6 with port: bracket notation "[addr]:port" void test_buildHostDisplay_ipv6_with_port() { - CPPUNIT_ASSERT_EQUAL( - std::string("[2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b]:61367"), - Ip::buildHostDisplay("2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b", 61367)); + CPPUNIT_ASSERT_EQUAL(std::string("[2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b]:61367"), Ip::buildHostDisplay("2a04:1c43:31da:0:7bb0:7ebc:5759:9a6b", 61367)); } // IPv6 with port <= 0: host returned as-is (no brackets added) - void test_buildHostDisplay_ipv6_no_port() { - CPPUNIT_ASSERT_EQUAL( - std::string("::1"), - Ip::buildHostDisplay("::1", 0)); - } + void test_buildHostDisplay_ipv6_no_port() { CPPUNIT_ASSERT_EQUAL(std::string("::1"), Ip::buildHostDisplay("::1", 0)); } // Hostname with port: plain "host:port" - void test_buildHostDisplay_hostname_with_port() { - CPPUNIT_ASSERT_EQUAL( - std::string("example.com:6234"), - Ip::buildHostDisplay("example.com", 6234)); - } + void test_buildHostDisplay_hostname_with_port() { CPPUNIT_ASSERT_EQUAL(std::string("example.com:6234"), Ip::buildHostDisplay("example.com", 6234)); } // buildHostDisplay output fed back into parseHostPort recovers the same // host and port — this is the round-trip that connectToServer() relies on