Search code examples
c++linuxsockets

Dual stack network server does not handle ipv4 requests as expected


Below is a minimal working example of a C++ server which implements a dual stack network infrastructure. (Meaning that a single socket handles both ipv4 and ipv6 connections.)

#include <format>
#include <print>
#include <cstring>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <signal.h>
#include <arpa/inet.h>
#include <unistd.h>

int main(int argc, char* argv[]) {

    const auto SERVER_PORT = 7778;

    const auto server_fd = socket(AF_INET6, SOCK_STREAM, 0);
    int opt = 0;

    setsockopt(server_fd, IPPROTO_IPV6, IPV6_V6ONLY, &opt, sizeof(opt));

    sockaddr_in6 server_address;
    std::memset(&server_address, 0, sizeof(server_address));

    server_address.sin6_family = AF_INET6;
    server_address.sin6_addr = in6addr_any;
    server_address.sin6_port = htons(SERVER_PORT);
    bind(server_fd, reinterpret_cast<sockaddr*>(&server_address), sizeof(server_address));

    listen(server_fd, 10);

    sockaddr_storage peer_address;
    socklen_t peer_address_length = sizeof(peer_address);
    auto peer_fd = accept(server_fd, reinterpret_cast<sockaddr*>(&peer_address), &peer_address_length);

    if(peer_address.ss_family == AF_INET)
    {
        const auto p_peer_address = &peer_address;
        sockaddr_in* ipv4 = (sockaddr_in*)p_peer_address;
        std::println("Client port (IPv4): {}", ntohs(ipv4->sin_port));
    }
    else if(peer_address.ss_family == AF_INET6)
    {
        const auto p_peer_address = &peer_address;
        sockaddr_in6* ipv6 = (sockaddr_in6*)p_peer_address;
        std::println("Client port (IPv6): {}", ntohs(ipv6->sin6_port));
    }
    else
    {
        throw std::runtime_error("unrecognized ss_family");
    }

    close(peer_fd);
    close(server_fd);

    return 0;
}

However, it is not working as expected. Regardless of whether a client connects via ipv4 or ipv6, the logic always follows the else if branch of the if statement. The first if branch never runs, indicating that no connections are started with the AF_INET family.

Both clients do actually appear to work. The server responds when a connection is started - it's just the ipv6 message is printed regardless of the client connection type. (ipv4 or ipv6)

If it makes a difference, the clients and server are both running on the same machine, connecting via localhost (127.0.0.1)

Example C++ code for both types of client are provided below.

// Client: ipv6

#include <format>
#include <print>
#include <format>
#include <cstring>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>


int main(int argc, char* argv[]) {

    const auto PORT = 7778;

    const auto socket_fd = socket(AF_INET6, SOCK_STREAM, 0);

    in6_addr server_sin6_address;
    std::memset(&server_sin6_address, 0, sizeof(server_sin6_address));
    inet_pton(AF_INET6, "::1", &server_sin6_address);

    sockaddr_storage server_address;
    std::memset(&server_address, 0, sizeof(server_address));

    server_address.ss_family = AF_INET6;
    sockaddr_storage *p_server_address = &server_address;
    sockaddr_in6 *p_server_address_in6 = reinterpret_cast<sockaddr_in6*>(p_server_address);
    p_server_address_in6->sin6_family = AF_INET6; // why repeat?
    p_server_address_in6->sin6_port = htons(PORT);
    p_server_address_in6->sin6_flowinfo = 0; // not used?
    p_server_address_in6->sin6_addr = server_sin6_address;
    p_server_address_in6->sin6_scope_id = 0; // not used?

    const auto connect_result = connect(socket_fd, reinterpret_cast<sockaddr*>(&server_address), sizeof(server_address));

    const char* const buffer = "hello world ipv6";
    const auto buffer_length = strlen(buffer) + 1;
    send(socket_fd, buffer, buffer_length, 0);

    close(socket_fd);

    return 0;
}
// Client: ipv4

#include <format>
#include <print>
#include <format>
#include <cstring>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>


int main(int argc, char* argv[]) {

    const auto PORT = 7778;

    const auto socket_fd = socket(AF_INET, SOCK_STREAM, 0);

    in_addr server_sin_address;
    std::memset(&server_sin_address, 0, sizeof(server_sin_address));
    inet_pton(AF_INET, "127.0.0.1", &server_sin_address);

    sockaddr_storage server_address;
    std::memset(&server_address, 0, sizeof(server_address));

    server_address.ss_family = AF_INET;
    sockaddr_storage *p_server_address = &server_address;
    sockaddr_in *p_server_address_in = reinterpret_cast<sockaddr_in*>(p_server_address);
    p_server_address_in->sin_family = AF_INET; // why repeat?
    p_server_address_in->sin_port = htons(PORT);
    p_server_address_in->sin_addr = server_sin_address;
    //p_server_address_in->sin_zero = 0; // not used?

    const auto connect_result = connect(socket_fd, reinterpret_cast<sockaddr*>(&server_address), sizeof(server_address));

    const char* const buffer = "hello world ipv4";
    const auto buffer_length = strlen(buffer) + 1;
    const auto send_result = send(socket_fd, buffer, buffer_length, 0);

    close(socket_fd);
    std::println("Server quit");

    return 0;
}

Maybe it's a bug in the ipv4 client implementation, but so far I didn't find anything which looked like it might be obviously incorrect.

Do ipv4 connections to an ipv6 destination somehow get automatically upgraded by the OS or something? Just seems like really weird behavior.


Solution

  • A dual-stack socket is an IPv6 socket which can communicate with an IPv4 peer (by disabling the IPV6_V6ONLY option). It is still an IPv6 socket nonetheless.

    An AF_INET socket can work only with AF_INET addresses. And likewise, an AF_INET6 socket can work only with AF_INET6 addresses. So, when a client is accepted by an AF_INET6 server, whether the server is dual-stack or not, the accepted socket will also be AF_INET6. Which is what you are seeing happen.

    But, for a dual-stack server, if the client is using IPv4 instead of IPv6, then the client's IP address that is reported by accept() or getpeername() will be an IPv4-mapped IPv6 address, ie an AF_INET6 address that has a 96-bit prefix 0:0:0:0:0:FFFF and the remaining 32-bits will be the IPv4 address.

    You can see this if you log the actual IPs, not just the ports.

    For example:

    if(peer_address.ss_family == AF_INET) // <-- never true for an AF_INET6 server!
    {
        const sockaddr_in* ipv4 = reinterpret_cast<sockaddr_in*>(&peer_address);
        char ipstr[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &ipv4->sin_addr, ipstr, sizeof(ipstr));
        std::println("Client IPv4: {}, port: {}", ipstr, ntohs(ipv4->sin_port));
    }
    else if(peer_address.ss_family == AF_INET6) // <-- always true for an AF_INET6 server!
    {
        const sockaddr_in6* ipv6 = reinterpret_cast<sockaddr_in6*>(&peer_address);
        char ipstr[INET6_ADDRSTRLEN];
        if (IN6_IS_ADDR_V4MAPPED(&sockaddr_in6->sin6_addr))
        {
            struct in_addr ipv4;
            memcpy(&ipv4.s_addr, &sockaddr_in6->sin6_addr.s6_addr[12], 4);
            inet_ntop(AF_INET, &ipv4, ipstr, sizeof(ipstr));
            std::println("Client IPv4 (mapped): {}, port: {}", ipstr, ntohs(ipv6->sin6_port));
        }
        else
        {
            inet_ntop(AF_INET6, &ipv6->sin6_addr, ipstr, sizeof(ipstr));
            std::println("Client IPv6: {}, port: {}", ipstr, ntohs(ipv6->sin6_port));
        }
    }
    else // <-- never happens on an AF_INET6 server!
    {
        throw std::runtime_error("unrecognized ss_family");
    }