#3 - DNS support + IPv6
This commit is contained in:
@@ -4,6 +4,7 @@ Game engine built as a modular monolith.
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
|
### Locally
|
||||||
Dependencies:
|
Dependencies:
|
||||||
- Conan 2 (`pipx install conan`, or `pip install conan`)
|
- Conan 2 (`pipx install conan`, or `pip install conan`)
|
||||||
- Vulkan SDK
|
- Vulkan SDK
|
||||||
@@ -42,7 +43,7 @@ Client is at `build/modules/client/mmo_client`
|
|||||||
|
|
||||||
First run the server, so that client can connect. The server will try to use port 8080, but if it is already occupied, it will try use the next free higher one.
|
First run the server, so that client can connect. The server will try to use port 8080, but if it is already occupied, it will try use the next free higher one.
|
||||||
|
|
||||||
## Docker build
|
### Docker
|
||||||
|
|
||||||
It also possible to build the project using Docker. Advantage is that you do not have to set up the environment. The current Dockerfile also uses older linux distribution to be backwards compatible with older systems. To build everything, build the Dockerfile at `docker/Dockerfile` with until target `builder`:
|
It also possible to build the project using Docker. Advantage is that you do not have to set up the environment. The current Dockerfile also uses older linux distribution to be backwards compatible with older systems. To build everything, build the Dockerfile at `docker/Dockerfile` with until target `builder`:
|
||||||
|
|
||||||
@@ -55,3 +56,29 @@ To easily export the built executables, run the target `export` of the same Dock
|
|||||||
```
|
```
|
||||||
$ docker build -f docker/Dockerfile --target export --output type=local,dest=dist .
|
$ docker build -f docker/Dockerfile --target export --output type=local,dest=dist .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
To run the system as inteded, run the `tw_server` executable first. It should output something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
[2026-08-05 16:43:53.806] [info] quicr-port=8101 cluster-port=8102 timescaledb=disabled
|
||||||
|
[2026-08-05 16:43:53.807] [info] Running on port: 8101
|
||||||
|
[2026-08-05 16:43:53.807] [info] ZoneClusterLink listening on port 8102
|
||||||
|
[2026-08-05 16:43:53.810] [info] Registered as zone 1 (-5000,-5000) (0,5000)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the `Running on port: 8101` as it reports on which port it is currently running. Use it when connecting the clients.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
Run the `tw_client` for connecting to the game. It should open into a lobby, where you can input an address of the server. It should remember the last address used and you can make some favourites. This is useful for testing on multiple cloud instances for example.
|
||||||
|
|
||||||
|
### Mock Client
|
||||||
|
|
||||||
|
The `tw_mock_client` module is for stress testing the server. It simulates 300 connected clients and moves them randomly. All those simulated players are actual established connections, which means the bandwidth of the server will be accurate.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-54
@@ -14,20 +14,14 @@
|
|||||||
|
|
||||||
ARG UBUNTU_VERSION=22.04
|
ARG UBUNTU_VERSION=22.04
|
||||||
|
|
||||||
# ── toolchain stage ──────────────────────────────────────────────────────────
|
|
||||||
FROM ubuntu:${UBUNTU_VERSION} AS toolchain
|
FROM ubuntu:${UBUNTU_VERSION} AS toolchain
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
# jammy's Vulkan headers are 1.3.204, which predates the VkBufferUsageFlags2
|
|
||||||
# constants the renderer uses, and its glslang predates the -gVS the shader
|
|
||||||
# target passes. LunarG publishes current headers, loader and glslang for jammy,
|
|
||||||
# and outranks the distro packages on version.
|
|
||||||
ARG VULKAN_SDK_VERSION=1.4.313
|
ARG VULKAN_SDK_VERSION=1.4.313
|
||||||
|
|
||||||
# The apt lists are kept: conan installs the xorg and egl system packages itself
|
RUN apt-get update \
|
||||||
# while resolving SDL.
|
&& apt-get install -y --no-install-recommends \
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
software-properties-common ca-certificates gnupg wget \
|
software-properties-common ca-certificates gnupg wget \
|
||||||
&& add-apt-repository -y ppa:ubuntu-toolchain-r/test \
|
&& add-apt-repository -y ppa:ubuntu-toolchain-r/test \
|
||||||
&& wget -qO /etc/apt/trusted.gpg.d/lunarg.asc \
|
&& wget -qO /etc/apt/trusted.gpg.d/lunarg.asc \
|
||||||
@@ -39,15 +33,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
git make ninja-build ccache pkg-config \
|
git make ninja-build ccache pkg-config \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
vulkan-headers libvulkan-dev glslang-tools \
|
vulkan-headers libvulkan-dev glslang-tools \
|
||||||
libdecor-0-dev
|
libdecor-0-dev libpqxx-dev
|
||||||
|
|
||||||
# cmake comes from pip because jammy ships 3.22 and the project asks for 3.26.
|
|
||||||
ARG CMAKE_VERSION=4.4.0
|
ARG CMAKE_VERSION=4.4.0
|
||||||
ARG CONAN_VERSION=2.31.1
|
ARG CONAN_VERSION=2.31.1
|
||||||
RUN pip3 install --no-cache-dir "cmake==${CMAKE_VERSION}" "conan==${CONAN_VERSION}"
|
RUN pip3 install --no-cache-dir "cmake==${CMAKE_VERSION}" "conan==${CONAN_VERSION}"
|
||||||
|
|
||||||
# Some autotools dependencies (flex, by way of SDL) run sub-configures that look
|
|
||||||
# for an unsuffixed gcc/cc and ignore $CC, so 14 has to answer to the plain names.
|
|
||||||
RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 100 \
|
RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 100 \
|
||||||
--slave /usr/bin/g++ g++ /usr/bin/g++-14 \
|
--slave /usr/bin/g++ g++ /usr/bin/g++-14 \
|
||||||
--slave /usr/bin/gcov gcov /usr/bin/gcov-14 \
|
--slave /usr/bin/gcov gcov /usr/bin/gcov-14 \
|
||||||
@@ -59,10 +50,9 @@ ENV CXX=g++
|
|||||||
|
|
||||||
COPY docker/conan/jammy /etc/conan/jammy
|
COPY docker/conan/jammy /etc/conan/jammy
|
||||||
|
|
||||||
# ── dependency stage ─────────────────────────────────────────────────────────
|
|
||||||
# Kept apart from the build so that editing sources does not rebuild SDL and
|
|
||||||
# protobuf. The conan cache lives in the image layer rather than a cache mount,
|
# toolchain installs Conan packages
|
||||||
# so the generators in /deps can never outlive the packages they point at.
|
|
||||||
FROM toolchain AS deps
|
FROM toolchain AS deps
|
||||||
|
|
||||||
COPY conanfile.txt /src/conanfile.txt
|
COPY conanfile.txt /src/conanfile.txt
|
||||||
@@ -72,19 +62,14 @@ RUN conan install /src/conanfile.txt \
|
|||||||
--build=missing \
|
--build=missing \
|
||||||
--output-folder=/deps
|
--output-folder=/deps
|
||||||
|
|
||||||
# ── build stage ──────────────────────────────────────────────────────────────
|
|
||||||
# The build tree is a cache mount, so an edit rebuilds only what it touched.
|
|
||||||
# That is also why glm, entt and Jolt have to travel in the build context: their
|
# Builds the project
|
||||||
# FetchContent SOURCE_DIRs point into external/, while the stamps that record
|
|
||||||
# them as populated live in the cached build tree. Dropping them from the
|
|
||||||
# context would leave the stamps pointing at empty directories.
|
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# tw_server is absent on purpose: it needs libpqxx >= 7.7 for pqxx::params and
|
|
||||||
# jammy carries 6.4.
|
|
||||||
RUN --mount=type=cache,target=/build \
|
RUN --mount=type=cache,target=/build \
|
||||||
--mount=type=cache,target=/root/.ccache \
|
--mount=type=cache,target=/root/.ccache \
|
||||||
cmake -S /src -B /build -G Ninja \
|
cmake -S /src -B /build -G Ninja \
|
||||||
@@ -94,15 +79,7 @@ RUN --mount=type=cache,target=/build \
|
|||||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
|
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
|
||||||
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
|
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
|
||||||
-DCMAKE_EXE_LINKER_FLAGS="-static-libstdc++ -static-libgcc" \
|
-DCMAKE_EXE_LINKER_FLAGS="-static-libstdc++ -static-libgcc" \
|
||||||
&& cmake --build /build --target \
|
&& cmake --build /build \
|
||||||
tw_client \
|
|
||||||
tw_mock_client \
|
|
||||||
tw_chat_server_exe \
|
|
||||||
tw_chat_mock_client \
|
|
||||||
tw_message_protocol_tests \
|
|
||||||
tw_metrics_tests \
|
|
||||||
tw_network_tests \
|
|
||||||
tw_peer_to_peer_tests \
|
|
||||||
&& mkdir -p /out/bin /out/shaders \
|
&& mkdir -p /out/bin /out/shaders \
|
||||||
&& cp /build/modules/client/tw_client \
|
&& cp /build/modules/client/tw_client \
|
||||||
/build/modules/mock_client/tw_mock_client \
|
/build/modules/mock_client/tw_mock_client \
|
||||||
@@ -111,37 +88,20 @@ RUN --mount=type=cache,target=/build \
|
|||||||
/out/bin/ \
|
/out/bin/ \
|
||||||
&& cp /build/modules/client/shaders/*.spirv /out/shaders/
|
&& cp /build/modules/client/shaders/*.spirv /out/shaders/
|
||||||
|
|
||||||
# ── unit test stage ──────────────────────────────────────────────────────────
|
|
||||||
# Catch2 suites only. The process harnesses below need peers and a network, so
|
|
||||||
# they run as compose services instead.
|
|
||||||
FROM builder AS test
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/build \
|
|
||||||
ctest --test-dir /build --output-on-failure
|
|
||||||
|
|
||||||
# ── client export stage ──────────────────────────────────────────────────────
|
# Extracts client
|
||||||
# Not runnable as a container; the client needs a GPU and a display. Extract it:
|
|
||||||
# docker build -f docker/Dockerfile --target export --output type=local,dest=dist .
|
|
||||||
FROM scratch AS export
|
FROM scratch AS export
|
||||||
|
|
||||||
COPY --from=builder /out/bin/tw_client /tw_client
|
COPY --from=builder /out/bin/tw_client /tw_client
|
||||||
COPY --from=builder /out/shaders/ /shaders/
|
COPY --from=builder /out/shaders/ /shaders/
|
||||||
|
|
||||||
# ── runtime stages ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
# Mock client
|
||||||
FROM gcr.io/distroless/base-debian12:nonroot AS mock-client
|
FROM gcr.io/distroless/base-debian12:nonroot AS mock-client
|
||||||
|
|
||||||
COPY --from=builder /out/bin/tw_mock_client /usr/local/bin/
|
COPY --from=builder /out/bin/tw_mock_client /usr/local/bin/
|
||||||
ENTRYPOINT ["/usr/local/bin/tw_mock_client"]
|
ENTRYPOINT ["/usr/local/bin/tw_mock_client"]
|
||||||
|
|
||||||
|
|
||||||
FROM gcr.io/distroless/base-debian12:nonroot AS chat-server
|
|
||||||
|
|
||||||
COPY --from=builder /out/bin/tw_chat_server_exe /usr/local/bin/
|
|
||||||
EXPOSE 8101/udp
|
|
||||||
ENTRYPOINT ["/usr/local/bin/tw_chat_server_exe"]
|
|
||||||
|
|
||||||
|
|
||||||
FROM gcr.io/distroless/base-debian12:nonroot AS chat-mock-client
|
|
||||||
|
|
||||||
COPY --from=builder /out/bin/tw_chat_mock_client /usr/local/bin/
|
|
||||||
ENTRYPOINT ["/usr/local/bin/tw_chat_mock_client"]
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#include "ClientArgs.hpp"
|
#include "ClientArgs.hpp"
|
||||||
|
|
||||||
#include <arpa/inet.h>
|
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -25,12 +24,6 @@ std::string_view trim(std::string_view str) {
|
|||||||
return str.substr(start, end - start);
|
return str.substr(start, end - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool is_valid_ipv4(std::string_view ip_str) {
|
|
||||||
// Use inet_pton to validate IPv4 format
|
|
||||||
struct in_addr addr;
|
|
||||||
return inet_pton(AF_INET, std::string(ip_str).c_str(), &addr) == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
tl::expected<int, std::string> parse_port(std::string_view port_str) {
|
tl::expected<int, std::string> parse_port(std::string_view port_str) {
|
||||||
if(port_str.empty()) {
|
if(port_str.empty()) {
|
||||||
return 8080; // Default port
|
return 8080; // Default port
|
||||||
@@ -63,15 +56,27 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
|
|||||||
return tl::make_unexpected("address cannot be empty");
|
return tl::make_unexpected("address cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the colon to split host and port
|
|
||||||
size_t colon_pos = text.rfind(':');
|
|
||||||
|
|
||||||
std::string_view host;
|
std::string_view host;
|
||||||
std::string_view port_str;
|
std::string_view port_str;
|
||||||
|
|
||||||
if(colon_pos == std::string_view::npos) {
|
// An IPv6 literal carries colons of its own, so the brackets it is written
|
||||||
// No colon found: treat entire string as port or host
|
// in are what says where the host ends. This is the form to_string() emits.
|
||||||
// If it's all digits, treat as port; otherwise as host (will fail validation)
|
if(text.front() == '[') {
|
||||||
|
size_t closing = text.find(']');
|
||||||
|
if(closing == std::string_view::npos) {
|
||||||
|
return tl::make_unexpected("address is missing a closing bracket");
|
||||||
|
}
|
||||||
|
|
||||||
|
host = text.substr(1, closing - 1);
|
||||||
|
port_str = text.substr(closing + 1);
|
||||||
|
|
||||||
|
if(!port_str.empty()) {
|
||||||
|
if(port_str.front() != ':') {
|
||||||
|
return tl::make_unexpected("expected a port after the closing bracket");
|
||||||
|
}
|
||||||
|
port_str.remove_prefix(1);
|
||||||
|
}
|
||||||
|
} else if(size_t colon_pos = text.rfind(':'); colon_pos == std::string_view::npos) {
|
||||||
bool all_digits = !text.empty() && std::all_of(text.begin(), text.end(),
|
bool all_digits = !text.empty() && std::all_of(text.begin(), text.end(),
|
||||||
[](unsigned char c) { return std::isdigit(c); });
|
[](unsigned char c) { return std::isdigit(c); });
|
||||||
|
|
||||||
@@ -79,7 +84,6 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
|
|||||||
host = "127.0.0.1";
|
host = "127.0.0.1";
|
||||||
port_str = text;
|
port_str = text;
|
||||||
} else {
|
} else {
|
||||||
// Treat as host with no port
|
|
||||||
host = text;
|
host = text;
|
||||||
port_str = "";
|
port_str = "";
|
||||||
}
|
}
|
||||||
@@ -93,10 +97,6 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
|
|||||||
return tl::make_unexpected("host cannot be empty");
|
return tl::make_unexpected("host cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!is_valid_ipv4(host)) {
|
|
||||||
return tl::make_unexpected("not a valid IPv4 address");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse port
|
// Parse port
|
||||||
auto port_result = parse_port(port_str);
|
auto port_result = parse_port(port_str);
|
||||||
if(!port_result) {
|
if(!port_result) {
|
||||||
@@ -104,7 +104,13 @@ tl::expected<net::Address, std::string> parse_address(std::string_view text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int port = port_result.value();
|
int port = port_result.value();
|
||||||
return net::Address(std::optional<std::string>(std::string(host)), port);
|
|
||||||
|
auto address_r = net::Address::resolve(std::string(host), port);
|
||||||
|
if(!address_r) {
|
||||||
|
return tl::make_unexpected(address_r.error().message());
|
||||||
|
}
|
||||||
|
|
||||||
|
return address_r.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<std::string> server_arg(int argc, char** argv) {
|
std::optional<std::string> server_arg(int argc, char** argv) {
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ namespace tw::app {
|
|||||||
/**
|
/**
|
||||||
* Parse an address string into a network address.
|
* Parse an address string into a network address.
|
||||||
*
|
*
|
||||||
* Accepts "host:port" or a bare port number. Bare port uses 127.0.0.1.
|
* Accepts "host:port" or a bare port number, where the host may be a name as
|
||||||
* Missing port defaults to 8080. Trims surrounding whitespace.
|
* well as an address literal; an IPv6 literal has to be bracketed, as
|
||||||
* Validates the host with inet_pton and returns an error string
|
* "[::1]:8080". Bare port uses 127.0.0.1. Missing port defaults to 8080.
|
||||||
* for non-IPv4 addresses or invalid ports.
|
* Trims surrounding whitespace.
|
||||||
|
*
|
||||||
|
* Looks the host up, so it blocks for as long as that takes, and returns an
|
||||||
|
* error string for a host that does not resolve or an invalid port.
|
||||||
*/
|
*/
|
||||||
tl::expected<net::Address, std::string> parse_address(std::string_view text);
|
tl::expected<net::Address, std::string> parse_address(std::string_view text);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstring>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace tw::net {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed host lookup.
|
||||||
|
*
|
||||||
|
* Kept apart from NetworkError because getaddrinfo reports EAI_ codes, which
|
||||||
|
* are their own mostly-negative space: sharing one enum would map a lookup
|
||||||
|
* failure onto whichever errno happened to carry the same number.
|
||||||
|
*/
|
||||||
|
struct ResolutionError {
|
||||||
|
int m_code;
|
||||||
|
int m_errno;
|
||||||
|
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Only EAI_SYSTEM defers to errno, and errno will not have survived by the
|
||||||
|
* time message() runs, so it is captured here.
|
||||||
|
*/
|
||||||
|
static ResolutionError from_gai(int code) {
|
||||||
|
return { code, errno };
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string message() const {
|
||||||
|
switch (m_code) {
|
||||||
|
case EAI_NONAME:
|
||||||
|
return "The host name is not known.";
|
||||||
|
case EAI_AGAIN:
|
||||||
|
return "The name server is unreachable or busy; the lookup may succeed later.";
|
||||||
|
case EAI_FAIL:
|
||||||
|
return "The name server returned a permanent failure.";
|
||||||
|
case EAI_FAMILY:
|
||||||
|
return "The requested address family is not supported.";
|
||||||
|
case EAI_SERVICE:
|
||||||
|
return "The requested port is not available for this socket type.";
|
||||||
|
case EAI_MEMORY:
|
||||||
|
return "Insufficient memory was available to complete the lookup.";
|
||||||
|
case EAI_SYSTEM:
|
||||||
|
return std::string(strerror(m_errno));
|
||||||
|
default:
|
||||||
|
return std::string(gai_strerror(m_code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ResolutionError.hpp"
|
||||||
|
#include "tl/expected.hpp"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <string>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
|
||||||
|
namespace tw::net {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a host name or an address literal into a socket address.
|
||||||
|
*
|
||||||
|
* `family` is the family of the socket the result will be given to. AF_INET6
|
||||||
|
* asks for IPv4-only names as ::ffff: mapped addresses, so that one dual-stack
|
||||||
|
* socket reaches both; AF_UNSPEC takes the name as it comes and suits addresses
|
||||||
|
* that are only being validated, displayed or stored.
|
||||||
|
*
|
||||||
|
* Blocks for the length of a DNS round trip when the name is not already known,
|
||||||
|
* so it belongs at connect time rather than anywhere periodic.
|
||||||
|
*/
|
||||||
|
inline tl::expected<sockaddr_storage, ResolutionError>
|
||||||
|
resolve_host(const std::string& host, int port, sa_family_t family = AF_UNSPEC) {
|
||||||
|
addrinfo hints {};
|
||||||
|
hints.ai_family = family;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
|
||||||
|
// AI_ADDRCONFIG is deliberately absent. Together with AF_INET6 it discards
|
||||||
|
// every result on a host that carries no global IPv6 address, which is the
|
||||||
|
// default state of a container on a bridge network.
|
||||||
|
if(family == AF_INET6) {
|
||||||
|
hints.ai_flags = AI_V4MAPPED | AI_ALL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passing the port as the service spares us setting sin_port or sin6_port
|
||||||
|
// by hand once the family of the answer is known.
|
||||||
|
const std::string service = std::to_string(port);
|
||||||
|
|
||||||
|
addrinfo* results = nullptr;
|
||||||
|
const int rc = ::getaddrinfo(host.c_str(), service.c_str(), &hints, &results);
|
||||||
|
if(rc != 0) {
|
||||||
|
return tl::make_unexpected(ResolutionError::from_gai(rc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list arrives ordered by RFC 6724, so the head is the address the
|
||||||
|
// system would have picked for itself.
|
||||||
|
sockaddr_storage storage {};
|
||||||
|
std::memcpy(&storage, results->ai_addr, results->ai_addrlen);
|
||||||
|
|
||||||
|
::freeaddrinfo(results);
|
||||||
|
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -70,7 +70,13 @@ MessageConnection* MessageEndpoint::add_peer(net::quicr::QuicrConnection* connec
|
|||||||
}
|
}
|
||||||
|
|
||||||
tl::expected<MessageConnection*, MessageError> MessageEndpoint::connect(const std::string& host, int port) {
|
tl::expected<MessageConnection*, MessageError> MessageEndpoint::connect(const std::string& host, int port) {
|
||||||
auto connection_r = m_endpoint->connect(net::quicr::QuicrAddress(host, port));
|
auto address_r = net::quicr::QuicrAddress::resolve(host, port, m_endpoint->family());
|
||||||
|
if(!address_r) {
|
||||||
|
return tl::make_unexpected(
|
||||||
|
MessageError(MessageErrorType::ConnectFailed, address_r.error().message()));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto connection_r = m_endpoint->connect(address_r.value());
|
||||||
if(!connection_r) {
|
if(!connection_r) {
|
||||||
return tl::make_unexpected(
|
return tl::make_unexpected(
|
||||||
MessageError(MessageErrorType::ConnectFailed, connection_r.error().message()));
|
MessageError(MessageErrorType::ConnectFailed, connection_r.error().message()));
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ public:
|
|||||||
};
|
};
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
const int NUM_CLIENTS = 10;
|
const int NUM_CLIENTS = 300;
|
||||||
tw::net::Address address = {"127.0.0.1", 8101};
|
tw::net::Address address = {"127.0.0.1", 8101};
|
||||||
|
|
||||||
std::vector<std::thread> threads;
|
std::vector<std::thread> threads;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "io/HostResolver.hpp"
|
||||||
|
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -7,6 +9,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <format>
|
#include <format>
|
||||||
|
#include <tl/expected.hpp>
|
||||||
|
|
||||||
namespace tw::net {
|
namespace tw::net {
|
||||||
|
|
||||||
@@ -38,6 +41,24 @@ public:
|
|||||||
: m_storage(storage)
|
: m_storage(storage)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look a host up, accepting a name where the constructor above takes only
|
||||||
|
* an address literal.
|
||||||
|
*
|
||||||
|
* `family` should be the family of the socket the address will be used
|
||||||
|
* with. The default suits an address that is only being validated or
|
||||||
|
* displayed, and takes whatever the name resolves to.
|
||||||
|
*/
|
||||||
|
static tl::expected<Address, ResolutionError>
|
||||||
|
resolve(const std::string& host, int port, sa_family_t family = AF_UNSPEC) {
|
||||||
|
auto storage_r = resolve_host(host, port, family);
|
||||||
|
if(!storage_r) {
|
||||||
|
return tl::make_unexpected(storage_r.error());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Address(std::move(storage_r.value()));
|
||||||
|
}
|
||||||
|
|
||||||
/** Return a const pointer suitable for connect / sendto / bind. */
|
/** Return a const pointer suitable for connect / sendto / bind. */
|
||||||
const struct sockaddr* sockaddr() const {
|
const struct sockaddr* sockaddr() const {
|
||||||
return reinterpret_cast<const struct sockaddr*>(&m_storage);
|
return reinterpret_cast<const struct sockaddr*>(&m_storage);
|
||||||
|
|||||||
@@ -15,7 +15,18 @@ QuicrPeerLink::QuicrPeerLink(uint32_t self_id, uint16_t port)
|
|||||||
{}
|
{}
|
||||||
|
|
||||||
void QuicrPeerLink::connect_to(uint32_t peer_id, const tw::net::Address& addr) {
|
void QuicrPeerLink::connect_to(uint32_t peer_id, const tw::net::Address& addr) {
|
||||||
auto r = m_endpoint->connect(net::quicr::QuicrAddress(addr.ip_string(), addr.port()));
|
// The address is rebuilt from its text, which for a mapped or IPv6 peer is
|
||||||
|
// more than the literal constructor can parse, so it goes back through the
|
||||||
|
// resolver — into the endpoint's family, since that is what will send it.
|
||||||
|
auto address_r = net::quicr::QuicrAddress::resolve(addr.ip_string(), addr.port(),
|
||||||
|
m_endpoint->family());
|
||||||
|
if (!address_r) {
|
||||||
|
spdlog::warn("QuicrPeerLink[{}]: address of peer {} failed to resolve: {}",
|
||||||
|
m_self_id, peer_id, address_r.error().message());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto r = m_endpoint->connect(address_r.value());
|
||||||
if (!r) {
|
if (!r) {
|
||||||
spdlog::warn("QuicrPeerLink[{}]: connect to peer {} failed", m_self_id, peer_id);
|
spdlog::warn("QuicrPeerLink[{}]: connect to peer {} failed", m_self_id, peer_id);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "io/HostResolver.hpp"
|
||||||
|
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -7,6 +9,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <format>
|
#include <format>
|
||||||
|
#include <tl/expected.hpp>
|
||||||
|
|
||||||
namespace tw::net::quicr {
|
namespace tw::net::quicr {
|
||||||
|
|
||||||
@@ -38,6 +41,24 @@ public:
|
|||||||
: m_storage(storage)
|
: m_storage(storage)
|
||||||
{ }
|
{ }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look a host up, accepting a name where the constructor above takes only
|
||||||
|
* an address literal.
|
||||||
|
*
|
||||||
|
* `family` should be the family of the endpoint socket the address will be
|
||||||
|
* sent from, so that a dual-stack socket is handed a mapped address rather
|
||||||
|
* than a bare IPv4 one.
|
||||||
|
*/
|
||||||
|
static tl::expected<QuicrAddress, ResolutionError>
|
||||||
|
resolve(const std::string& host, int port, sa_family_t family = AF_UNSPEC) {
|
||||||
|
auto storage_r = resolve_host(host, port, family);
|
||||||
|
if(!storage_r) {
|
||||||
|
return tl::make_unexpected(storage_r.error());
|
||||||
|
}
|
||||||
|
|
||||||
|
return QuicrAddress(std::move(storage_r.value()));
|
||||||
|
}
|
||||||
|
|
||||||
/** Return a const pointer suitable for connect / sendto / bind. */
|
/** Return a const pointer suitable for connect / sendto / bind. */
|
||||||
const struct sockaddr* sockaddr() const {
|
const struct sockaddr* sockaddr() const {
|
||||||
return reinterpret_cast<const struct sockaddr*>(&m_storage);
|
return reinterpret_cast<const struct sockaddr*>(&m_storage);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class QuicrConnectionListener;
|
|||||||
|
|
||||||
class QuicrEndpoint {
|
class QuicrEndpoint {
|
||||||
int32_t m_socket_fd;
|
int32_t m_socket_fd;
|
||||||
|
sa_family_t m_family;
|
||||||
std::unordered_map<uint64_t, std::shared_ptr<QuicrConnection>> m_connections;
|
std::unordered_map<uint64_t, std::shared_ptr<QuicrConnection>> m_connections;
|
||||||
|
|
||||||
std::vector<std::byte> m_inbound_buffer;
|
std::vector<std::byte> m_inbound_buffer;
|
||||||
@@ -26,7 +27,7 @@ class QuicrEndpoint {
|
|||||||
|
|
||||||
void process_datagram(std::span<std::byte> datagram, QuicrAddress from);
|
void process_datagram(std::span<std::byte> datagram, QuicrAddress from);
|
||||||
|
|
||||||
QuicrEndpoint(int socket_fd);
|
QuicrEndpoint(int socket_fd, sa_family_t family);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
QuicrEndpoint(const QuicrEndpoint&) = delete;
|
QuicrEndpoint(const QuicrEndpoint&) = delete;
|
||||||
@@ -47,6 +48,12 @@ public:
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The family the socket was opened with. Addresses have to be resolved
|
||||||
|
* into it before they can be sent to.
|
||||||
|
*/
|
||||||
|
sa_family_t family() const { return m_family; }
|
||||||
|
|
||||||
static tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> create();
|
static tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> create();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
namespace tw::net::quicr {
|
namespace tw::net::quicr {
|
||||||
|
|
||||||
QuicrEndpoint::QuicrEndpoint(int socket_fd)
|
QuicrEndpoint::QuicrEndpoint(int socket_fd, sa_family_t family)
|
||||||
: m_inbound_buffer(64 * 1024), m_socket_fd(socket_fd),
|
: m_inbound_buffer(64 * 1024), m_socket_fd(socket_fd), m_family(family),
|
||||||
m_new_connection_handler(nullptr) {
|
m_new_connection_handler(nullptr) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -31,29 +31,57 @@ tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> QuicrEndpoint::create_a
|
|||||||
}
|
}
|
||||||
|
|
||||||
tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> QuicrEndpoint::create() {
|
tl::expected<std::unique_ptr<QuicrEndpoint>, QuicrError> QuicrEndpoint::create() {
|
||||||
const int domain = AF_INET;
|
// An IPv6 socket with IPV6_V6ONLY cleared also carries IPv4 peers, which
|
||||||
|
// arrive as ::ffff: mapped addresses. A host with IPv6 switched off answers
|
||||||
|
// EAFNOSUPPORT instead, and there the endpoint stays IPv4 as it was.
|
||||||
|
sa_family_t domain = AF_INET6;
|
||||||
int socket_fd = socket(domain, SOCK_DGRAM, IPPROTO_UDP);
|
int socket_fd = socket(domain, SOCK_DGRAM, IPPROTO_UDP);
|
||||||
|
if(socket_fd < 0 && errno == EAFNOSUPPORT) {
|
||||||
|
domain = AF_INET;
|
||||||
|
socket_fd = socket(domain, SOCK_DGRAM, IPPROTO_UDP);
|
||||||
|
}
|
||||||
|
|
||||||
if(socket_fd < 0) {
|
if(socket_fd < 0) {
|
||||||
spdlog::error("Failed to create socket: {}", strerror(errno));
|
spdlog::error("Failed to create socket: {}", strerror(errno));
|
||||||
return tl::make_unexpected(QuicrError::from_errno(errno));
|
return tl::make_unexpected(QuicrError::from_errno(errno));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(domain == AF_INET6) {
|
||||||
|
const int v6_only = 0;
|
||||||
|
if(setsockopt(socket_fd, IPPROTO_IPV6, IPV6_V6ONLY, &v6_only, sizeof(v6_only)) < 0) {
|
||||||
|
spdlog::error("Failed to accept IPv4 peers on the socket: {}", strerror(errno));
|
||||||
|
::close(socket_fd);
|
||||||
|
return tl::make_unexpected(QuicrError::from_errno(errno));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(fcntl(socket_fd, F_SETFL, fcntl(socket_fd, F_GETFL, 0) | O_NONBLOCK, 1) == -1) {
|
if(fcntl(socket_fd, F_SETFL, fcntl(socket_fd, F_GETFL, 0) | O_NONBLOCK, 1) == -1) {
|
||||||
spdlog::error("Failed to set non-blocking mode: {}", strerror(errno));
|
spdlog::error("Failed to set non-blocking mode: {}", strerror(errno));
|
||||||
return tl::make_unexpected(QuicrError::from_errno(errno));
|
return tl::make_unexpected(QuicrError::from_errno(errno));
|
||||||
}
|
}
|
||||||
|
|
||||||
return std::unique_ptr<QuicrEndpoint>(new QuicrEndpoint(socket_fd));
|
return std::unique_ptr<QuicrEndpoint>(new QuicrEndpoint(socket_fd, domain));
|
||||||
}
|
}
|
||||||
|
|
||||||
tl::expected<void, QuicrError> QuicrEndpoint::bind(int port) {
|
tl::expected<void, QuicrError> QuicrEndpoint::bind(int port) {
|
||||||
const int domain = AF_INET;
|
sockaddr_storage storage = {};
|
||||||
struct sockaddr_in addr = {};
|
socklen_t length;
|
||||||
addr.sin_family = domain;
|
|
||||||
addr.sin_port = htons(port);
|
|
||||||
addr.sin_addr.s_addr = INADDR_ANY;
|
|
||||||
|
|
||||||
if(::bind(m_socket_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
|
if(m_family == AF_INET6) {
|
||||||
|
auto& addr = reinterpret_cast<sockaddr_in6&>(storage);
|
||||||
|
addr.sin6_family = AF_INET6;
|
||||||
|
addr.sin6_port = htons(port);
|
||||||
|
addr.sin6_addr = in6addr_any;
|
||||||
|
length = sizeof(sockaddr_in6);
|
||||||
|
} else {
|
||||||
|
auto& addr = reinterpret_cast<sockaddr_in&>(storage);
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(port);
|
||||||
|
addr.sin_addr.s_addr = INADDR_ANY;
|
||||||
|
length = sizeof(sockaddr_in);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(::bind(m_socket_fd, reinterpret_cast<struct sockaddr*>(&storage), length) < 0) {
|
||||||
spdlog::error("Failed to bind socket: {}", strerror(errno));
|
spdlog::error("Failed to bind socket: {}", strerror(errno));
|
||||||
return tl::make_unexpected(QuicrError::from_errno(errno));
|
return tl::make_unexpected(QuicrError::from_errno(errno));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user