Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ 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)
endif()
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Usage: ./https_dns_proxy [-a <listen_addr>] [-p <listen_port>] [-T <tcp_client_l
supports it (http, https, socks4a, socks5h), otherwise
initial DNS resolution will still be done via the
bootstrap DNS servers.
-S source_addr Source IPv4/v6 address for outbound HTTPS connections.
-S source_addr Source IPv4/v6 address for outbound HTTPS and bootstrap DNS.
(Default: system default)
-x Use HTTP/1.1 instead of HTTP/2. Useful with broken
or limited builds of libcurl.
Expand Down Expand Up @@ -231,6 +231,17 @@ pip3 install robotframework
python3 -m robot.run tests/robot/functional_tests.robot
```

## Docker bootstrap DNS test

There is a repeatable Docker-based test for validating bootstrap DNS behavior
and `-S` source address binding:

```
tests/docker/bootstrap_dns_test.sh
```

If your Docker CLI is not on `PATH`, you can set `DOCKER_BIN` to its full path.

## TODO

* Add some tests.
Expand All @@ -241,4 +252,3 @@ python3 -m robot.run tests/robot/functional_tests.robot
* Aaron Drew (aarond10@gmail.com): Original https_dns_proxy.
* Soumya ([github.com/soumya92](https://github.com/soumya92)): RFC 8484 implementation.
* baranyaib90 ([github.com/baranyaib90](https://github.com/baranyaib90)): fixes and improvements.

33 changes: 33 additions & 0 deletions src/dns_poller.c
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#include <arpa/inet.h>
#include <netdb.h>
#include <string.h>

Expand Down Expand Up @@ -113,6 +114,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};
Expand Down Expand Up @@ -165,6 +196,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);
Expand Down Expand Up @@ -193,6 +225,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;
Expand Down
2 changes: 2 additions & 0 deletions src/dns_poller.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ 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
// dns_poller_cleanup called.
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);

Expand Down
3 changes: 2 additions & 1 deletion src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,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);
Expand Down
2 changes: 1 addition & 1 deletion src/options.c
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
25 changes: 25 additions & 0 deletions tests/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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 \
&& rm -rf /var/lib/apt/lists/*

# Install Robot Framework
RUN pip3 install --break-system-packages robotframework

WORKDIR /src

# Default command runs all tests
CMD ["bash", "-c", "cmake -S . -B build && cmake --build build && cd tests/robot && python3 -m robot.run functional_tests.robot"]
75 changes: 75 additions & 0 deletions tests/docker/bootstrap_dns_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail

# Quick test for bootstrap DNS source binding feature
#
# When to use:
# - Quick verification during feature development
# - Testing bootstrap DNS feature specifically
# - Developing on macOS (proxy uses Linux-specific syscalls like accept4, MSG_MORE)
#
# Runtime: ~30 seconds

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

net_a="hdp_net_a_$$"
net_b="hdp_net_b_$$"
container="hdp_bootstrap_test_$$"

cleanup() {
"$docker_bin" rm -f "$container" >/dev/null 2>&1 || true
"$docker_bin" network rm "$net_a" "$net_b" >/dev/null 2>&1 || true
}
trap cleanup EXIT

"$docker_bin" network create "$net_a" >/dev/null
"$docker_bin" network create "$net_b" >/dev/null

"$docker_bin" run -d --name "$container" --network "$net_a" \
-v "$PWD":/src -w /src ubuntu:24.04 sleep infinity >/dev/null
"$docker_bin" network connect "$net_b" "$container"

"$docker_bin" exec "$container" bash -lc \
"export DEBIAN_FRONTEND=noninteractive; \
printf 'nameserver 1.1.1.1\n' > /etc/resolv.conf; \
apt-get update; \
apt-get install -y iproute2 cmake build-essential \
libcurl4-openssl-dev libc-ares-dev libev-dev dnsutils"

eth0_ip="$("$docker_bin" exec "$container" bash -lc \
\"ip -4 -br addr show eth0 | awk '{print \\$3}' | cut -d/ -f1\")"

if [[ -z "$eth0_ip" ]]; then
echo "Failed to detect eth0 IP in container." >&2
exit 1
fi

"$docker_bin" exec "$container" bash -lc \
"cmake -S /src -B /tmp/build >/dev/null && cmake --build /tmp/build >/dev/null"

"$docker_bin" exec "$container" bash -lc \
"printf 'nameserver 203.0.113.1\n' > /etc/resolv.conf; \
(/tmp/build/https_dns_proxy -a $eth0_ip -p 53 -S $eth0_ip \
-b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query -v -v -v \
> /tmp/hdp.log 2>&1 &) && sleep 1"

"$docker_bin" exec "$container" bash -lc \
"grep -q 'Received new DNS server IP' /tmp/hdp.log"

"$docker_bin" exec "$container" bash -lc \
"grep -q \"Using source address: $eth0_ip\" /tmp/hdp.log"

dig_out="$("$docker_bin" exec "$container" bash -lc \
\"dig @${eth0_ip} -p 53 example.com +short\")"

if [[ -z "$dig_out" ]]; then
echo "dig returned no results; proxy may not be resolving." >&2
"$docker_bin" exec "$container" bash -lc "tail -n 200 /tmp/hdp.log" || true
exit 1
fi

echo "OK: bootstrap DNS and DoH resolution succeeded via https_dns_proxy."
75 changes: 75 additions & 0 deletions tests/docker/run_all_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail

# Docker-based test runner for https_dns_proxy
# This script builds a test image and runs all tests in a proper Linux environment.
# Uses Docker's default bridge network which has NAT/masquerading properly configured
# for source address binding to work with external DNS servers.
#
# 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 and container names
image="https_dns_proxy_test:latest"
container="hdp_test_container_$$"

cleanup() {
echo ""
echo "==> Cleaning up..."
"$docker_bin" rm -f "$container" >/dev/null 2>&1 || true
}
trap cleanup EXIT

echo "==> Building Docker test image..."
"$docker_bin" build -t "$image" -f tests/docker/Dockerfile . -q

echo "==> Creating container on default bridge network..."
# Use default bridge network (--network=bridge or no --network flag)
# The default bridge has proper NAT/masquerading for source address binding
"$docker_bin" run -d --name "$container" \
--dns 1.1.1.1 --dns 8.8.8.8 \
-v "$PWD":/src -w /src "$image" sleep infinity >/dev/null

# Get eth0 IP address
eth0_ip=$("$docker_bin" exec "$container" bash -c \
"ip -4 -br addr show eth0 | awk '{print \$3}' | cut -d/ -f1")

if [[ -z "$eth0_ip" ]]; then
echo "Failed to detect eth0 IP in container." >&2
exit 1
fi

echo "Container eth0 IP: $eth0_ip"

echo ""
echo "==> Building project..."
"$docker_bin" exec "$container" bash -c \
"cmake -S /src -B /src/build && cmake --build /src/build" 2>&1 | \
grep -E "(Building|Linking|Built target)" || true

echo ""
echo "==> Running Robot Framework functional tests..."
"$docker_bin" exec "$container" bash -c \
"cd /src/tests/robot && python3 -m robot.run --loglevel WARN functional_tests.robot"

exit_code=$?

echo ""
if [ $exit_code -eq 0 ]; then
echo "✅ All tests passed!"
else
echo "⚠️ Some tests failed (exit code: $exit_code)"
echo "Check the Robot Framework report at tests/robot/report.html"
fi

exit $exit_code
41 changes: 39 additions & 2 deletions tests/robot/functional_tests.robot
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,45 @@ 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

# 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

# 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