From e73750bc55adfa1438b4eedd64efd1f67ca98f51 Mon Sep 17 00:00:00 2001 From: karl82 Date: Mon, 9 Feb 2026 06:41:36 -0800 Subject: [PATCH] Bind bootstrap DNS lookups to -S source address (#1) Motivation: ----------- PR #196 added the `-S` flag to bind HTTPS connections to a source address, enabling policy-based routing. However, bootstrap DNS queries (used to resolve DoH server hostnames like "dns.google") were not bound to the source address. This caused two issues: 1. **Privacy leak**: Bootstrap DNS queries go via default route (local ISP), exposing which DoH server you're using 2. **Routing mismatch**: HTTPS connection routes via VPN but may fail if resolved IP is unreachable from VPN Implementation: --------------- - Bind bootstrap DNS queries using `ares_set_local_ip4()` and `ares_set_local_ip6()` from c-ares - Validate address family matches proxy mode (`-4`/`-6`), warn on mismatch - Warn on invalid address literals - Robot Framework tests for source binding and validation warnings - Docker-based test infrastructure for CI/CD and macOS development Example Usage: -------------- ```bash https_dns_proxy -S 192.168.12.1 -b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query ``` With PBR rules routing traffic from source 192.168.12.1 via VPN: ```text # Route DoH HTTPS (port 443) via VPN config policy option name 'DoH WA via wg_wa' option interface 'wg_wa' option chain 'output' option proto 'tcp' option src_addr '192.168.12.1' option dest_port '443' # Route bootstrap DNS (port 53) via VPN config policy option name 'Bootstrap DNS WA via wg_wa' option interface 'wg_wa' option chain 'output' option proto 'udp' option src_addr '192.168.12.1' option dest_port '53' option dest_addr '1.1.1.1 8.8.8.8' ``` Both rules now match because `-S` binds both HTTPS and bootstrap DNS to the same source address. Verification: ------------- Bootstrap DNS bound to source address: ``` [I] dns_poller.c:163 Using source address: 192.168.12.1 [I] dns_poller.c:208 Received new DNS server IP: 142.250.80.110 for dns.google ``` Warning on address family mismatch: ``` [W] dns_poller.c:133 Bootstrap source address '::1' is IPv6, but IPv4-only mode is set ``` Warning on invalid address: ``` [W] dns_poller.c:141 Bootstrap source address 'not-an-ip' is not a valid IP literal ``` Files Modified: --------------- - `src/dns_poller.c`: Added `set_bootstrap_source_addr()` function - `src/dns_poller.h`: Added source_addr parameter to poller init - `src/main.c`: Pass source_addr to dns_poller - `src/options.c`: Fix format string type - `tests/robot/functional_tests.robot`: Source binding and validation tests - `tests/docker/Dockerfile`: Test image with valgrind and ctest integration - `tests/docker/run_all_tests.sh`: Simplified test runner using Dockerfile CMD - `CMakeLists.txt`: Fix robot test WORKING_DIRECTORY, add distclean target - `README.md`: Update Docker test documentation - `.gitignore`: Add build/ directory --- .gitignore | 1 + CMakeLists.txt | 10 ++++++- README.md | 14 ++++++++-- src/dns_poller.c | 33 +++++++++++++++++++++++ src/dns_poller.h | 2 ++ src/main.c | 3 ++- src/options.c | 2 +- tests/docker/Dockerfile | 27 +++++++++++++++++++ tests/docker/run_all_tests.sh | 27 +++++++++++++++++++ tests/robot/functional_tests.robot | 43 ++++++++++++++++++++++++++++-- 10 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 tests/docker/Dockerfile create mode 100755 tests/docker/run_all_tests.sh diff --git a/.gitignore b/.gitignore index 967c5bd..3320a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +build/ CMakeCache.txt CTestTestfile.cmake CMakeFiles/ diff --git a/CMakeLists.txt b/CMakeLists.txt index c67c195..11a5903 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -182,6 +182,14 @@ else() message(STATUS "python3 found: ${PYTHON3_EXE}") enable_testing() + + # Robot framework tests add_test(NAME robot COMMAND ${PYTHON3_EXE} -m robot.run functional_tests.robot - WORKING_DIRECTORY tests/robot) + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests/robot) endif() + +# Clean target (removes entire build directory) +add_custom_target(distclean + COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR} + COMMENT "Removing build directory" +) diff --git a/README.md b/README.md index c8aa5ab..8eb9dff 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Usage: ./https_dns_proxy [-a ] [-p ] [-T #include #include @@ -127,6 +128,36 @@ static void ares_cb(void *arg, int status, int __attribute__((unused)) timeouts, } } +static void set_bootstrap_source_addr(ares_channel channel, + const char *source_addr, + int family) { + if (!source_addr) { + return; + } + + struct in_addr addr_v4; + struct in6_addr addr_v6; + + if (inet_pton(AF_INET, source_addr, &addr_v4) == 1) { + if (family == AF_INET6) { + WLOG("Bootstrap source address '%s' is IPv4, but IPv6-only mode is set", + source_addr); + return; + } + ares_set_local_ip4(channel, ntohl(addr_v4.s_addr)); + } else if (inet_pton(AF_INET6, source_addr, &addr_v6) == 1) { + if (family == AF_INET) { + WLOG("Bootstrap source address '%s' is IPv6, but IPv4-only mode is set", + source_addr); + return; + } + ares_set_local_ip6(channel, (const unsigned char *)&addr_v6); + } else { + WLOG("Bootstrap source address '%s' is not a valid IP literal", source_addr); + return; + } +} + static ev_tstamp get_timeout(dns_poller_t *d) { static struct timeval max_tv = {.tv_sec = 5, .tv_usec = 0}; @@ -179,6 +210,7 @@ static void timer_cb(struct ev_loop __attribute__((unused)) *loop, void dns_poller_init(dns_poller_t *d, struct ev_loop *loop, const char *bootstrap_dns, int bootstrap_dns_polling_interval, + const char *source_addr, const char *hostname, int family, dns_poller_cb cb, void *cb_data) { int r = ares_library_init(ARES_LIB_INIT_ALL); @@ -207,6 +239,7 @@ void dns_poller_init(dns_poller_t *d, struct ev_loop *loop, d->loop = loop; d->hostname = hostname; d->family = family; + set_bootstrap_source_addr(d->ares, source_addr, family); d->cb = cb; d->polling_interval = bootstrap_dns_polling_interval; d->request_ongoing = 0; diff --git a/src/dns_poller.h b/src/dns_poller.h index e257220..6c411ce 100644 --- a/src/dns_poller.h +++ b/src/dns_poller.h @@ -37,6 +37,7 @@ typedef struct { // provided ev_loop. `bootstrap_dns` is a comma-separated list of DNS servers to // use for the lookup `hostname` every `interval_seconds`. For each successful // lookup, `cb` will be called with the resolved address. +// `source_addr` optionally binds bootstrap DNS lookups to a specific IP. // `family` should be AF_INET for IPv4 or AF_UNSPEC for both IPv4 and IPv6. // // Note: hostname *not* copied. It should remain valid until @@ -44,6 +45,7 @@ typedef struct { void dns_poller_init(dns_poller_t *d, struct ev_loop *loop, const char *bootstrap_dns, int bootstrap_dns_polling_interval, + const char *source_addr, const char *hostname, int family, dns_poller_cb cb, void *cb_data); diff --git a/src/main.c b/src/main.c index ca8c520..0ba7f9c 100644 --- a/src/main.c +++ b/src/main.c @@ -423,7 +423,8 @@ int main(int argc, char *argv[]) { if (hostname_from_url(opt.resolver_url, hostname, sizeof(hostname))) { app.using_dns_poller = 1; dns_poller_init(&dns_poller, loop, opt.bootstrap_dns, - opt.bootstrap_dns_polling_interval, hostname, + opt.bootstrap_dns_polling_interval, opt.source_addr, + hostname, opt.ipv4 ? AF_INET : AF_UNSPEC, dns_poll_cb, &app); ILOG("DNS polling initialized for '%s'", hostname); diff --git a/src/options.c b/src/options.c index 3630d11..1016341 100644 --- a/src/options.c +++ b/src/options.c @@ -254,7 +254,7 @@ void options_show_usage(int __attribute__((unused)) argc, char **argv) { printf(" supports it (http, https, socks4a, socks5h), otherwise\n"); printf(" initial DNS resolution will still be done via the\n"); printf(" bootstrap DNS servers.\n"); - printf(" -S source_addr Source IPv4/v6 address for outbound HTTPS connections.\n"); + printf(" -S source_addr Source IPv4/v6 address for outbound HTTPS and bootstrap DNS.\n"); printf(" (Default: system default)\n"); printf(" -x Use HTTP/1.1 instead of HTTP/2. Useful with broken\n" " or limited builds of libcurl.\n"); diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 0000000..0823bdb --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,27 @@ +FROM ubuntu:24.04 + +# Install all build and test dependencies in one layer +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + iproute2 \ + cmake \ + build-essential \ + libcurl4-openssl-dev \ + libc-ares-dev \ + libev-dev \ + libsystemd-dev \ + python3 \ + python3-pip \ + python3-venv \ + dnsutils \ + valgrind \ + && rm -rf /var/lib/apt/lists/* + +# Install Robot Framework +RUN pip3 install --break-system-packages robotframework + +WORKDIR /src + +# Default command: build and run tests +# Symlink needed because Robot test expects binary at project root +CMD ["bash", "-c", "cmake -S . -B build && cmake --build build && ln -sf build/https_dns_proxy https_dns_proxy && ctest --test-dir build --output-on-failure"] diff --git a/tests/docker/run_all_tests.sh b/tests/docker/run_all_tests.sh new file mode 100755 index 0000000..4931888 --- /dev/null +++ b/tests/docker/run_all_tests.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Docker-based test runner for https_dns_proxy +# +# When to use: +# - Full regression testing before commits/PRs +# - CI/CD pipelines +# - Developing on macOS (proxy uses Linux-specific syscalls like accept4, MSG_MORE) +# +# Runtime: ~2-3 minutes + +docker_bin="${DOCKER_BIN:-docker}" +if ! command -v "$docker_bin" >/dev/null 2>&1; then + echo "docker not found; set DOCKER_BIN or install Docker." >&2 + exit 1 +fi + +image="https_dns_proxy_test:latest" + +echo "==> Building Docker test image..." +"$docker_bin" build -t "$image" -f tests/docker/Dockerfile . -q + +echo "==> Running tests..." +"$docker_bin" run --rm \ + --dns 1.1.1.1 --dns 8.8.8.8 \ + -v "$PWD":/src "$image" diff --git a/tests/robot/functional_tests.robot b/tests/robot/functional_tests.robot index 84e2cf1..5e9125c 100644 --- a/tests/robot/functional_tests.robot +++ b/tests/robot/functional_tests.robot @@ -203,8 +203,47 @@ Truncate UDP Impossible ... Verify Truncation txtfill4096.test.dnscheck.tools 4096 12 100 ANSWER: 0 Source Address Binding - [Documentation] Test source address binding with -S flag + [Documentation] Test -S flag binds both HTTPS and bootstrap DNS to source address + [Tags] bootstrap + ${eth0_ip} = Run ip -4 addr show eth0 | grep inet | awk '{print $2}' | cut -d/ -f1 | tr -d '\\n' - Start Proxy -S ${eth0_ip} + + # Use explicit resolver hostname to force bootstrap DNS resolution + Start Proxy -S ${eth0_ip} -b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query + + # Wait for bootstrap DNS to complete + Sleep 2s + + # Verify source address binding was applied Set To Dictionary ${expected_logs} Using source address=1 + + # Verify bootstrap DNS completed successfully + Set To Dictionary ${expected_logs} Received new DNS server IP=1 + + # Verify no bootstrap DNS failures + Append To List ${error_logs} DNS lookup of 'dns.google' failed + + # Verify proxy works (HTTPS connection uses source binding) Run Dig + +Source Address Binding IPv6 With IPv4 Only Mode + [Documentation] Test that IPv6 source address with -4 flag logs warning + [Tags] bootstrap validation + + # Start proxy with IPv6 address in IPv4-only mode (-4 flag) + Start Proxy -4 -S ::1 -b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query + Sleep 1s + + # Verify warning is logged about address family mismatch + Set To Dictionary ${expected_logs} Bootstrap source address '::1' is IPv6, but IPv4-only mode is set=1 + +Source Address Binding Invalid Address + [Documentation] Test that invalid source address logs warning + [Tags] bootstrap validation + + # Start proxy with invalid IP address + Start Proxy -S not-an-ip-address -b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query + Sleep 1s + + # Verify warning is logged about invalid address + Set To Dictionary ${expected_logs} Bootstrap source address 'not-an-ip-address' is not a valid IP literal=1