Skip to content

Commit 8018812

Browse files
authored
Bind bootstrap DNS lookups to -S source address (#1) (#202)
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
1 parent 2f1e6ed commit 8018812

File tree

10 files changed

+155
-7
lines changed

10 files changed

+155
-7
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
build/
12
CMakeCache.txt
23
CTestTestfile.cmake
34
CMakeFiles/

CMakeLists.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ else()
182182
message(STATUS "python3 found: ${PYTHON3_EXE}")
183183

184184
enable_testing()
185+
186+
# Robot framework tests
185187
add_test(NAME robot COMMAND ${PYTHON3_EXE} -m robot.run functional_tests.robot
186-
WORKING_DIRECTORY tests/robot)
188+
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/tests/robot)
187189
endif()
190+
191+
# Clean target (removes entire build directory)
192+
add_custom_target(distclean
193+
COMMAND ${CMAKE_COMMAND} -E remove_directory ${CMAKE_BINARY_DIR}
194+
COMMENT "Removing build directory"
195+
)

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Usage: ./https_dns_proxy [-a <listen_addr>] [-p <listen_port>] [-T <tcp_client_l
187187
supports it (http, https, socks4a, socks5h), otherwise
188188
initial DNS resolution will still be done via the
189189
bootstrap DNS servers.
190-
-S source_addr Source IPv4/v6 address for outbound HTTPS connections.
190+
-S source_addr Source IPv4/v6 address for outbound HTTPS and bootstrap DNS.
191191
(Default: system default)
192192
-x Use HTTP/1.1 instead of HTTP/2. Useful with broken
193193
or limited builds of libcurl.
@@ -231,6 +231,17 @@ pip3 install robotframework
231231
python3 -m robot.run tests/robot/functional_tests.robot
232232
```
233233

234+
## Docker bootstrap DNS test
235+
236+
There is a repeatable Docker-based test suite for validating proxy behavior
237+
including `-S` source address binding:
238+
239+
```
240+
tests/docker/run_all_tests.sh
241+
```
242+
243+
If your Docker CLI is not on `PATH`, you can set `DOCKER_BIN` to its full path.
244+
234245
## TODO
235246

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

src/dns_poller.c

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#include <arpa/inet.h>
12
#include <netdb.h>
23
#include <string.h>
34

@@ -127,6 +128,36 @@ static void ares_cb(void *arg, int status, int __attribute__((unused)) timeouts,
127128
}
128129
}
129130

131+
static void set_bootstrap_source_addr(ares_channel channel,
132+
const char *source_addr,
133+
int family) {
134+
if (!source_addr) {
135+
return;
136+
}
137+
138+
struct in_addr addr_v4;
139+
struct in6_addr addr_v6;
140+
141+
if (inet_pton(AF_INET, source_addr, &addr_v4) == 1) {
142+
if (family == AF_INET6) {
143+
WLOG("Bootstrap source address '%s' is IPv4, but IPv6-only mode is set",
144+
source_addr);
145+
return;
146+
}
147+
ares_set_local_ip4(channel, ntohl(addr_v4.s_addr));
148+
} else if (inet_pton(AF_INET6, source_addr, &addr_v6) == 1) {
149+
if (family == AF_INET) {
150+
WLOG("Bootstrap source address '%s' is IPv6, but IPv4-only mode is set",
151+
source_addr);
152+
return;
153+
}
154+
ares_set_local_ip6(channel, (const unsigned char *)&addr_v6);
155+
} else {
156+
WLOG("Bootstrap source address '%s' is not a valid IP literal", source_addr);
157+
return;
158+
}
159+
}
160+
130161
static ev_tstamp get_timeout(dns_poller_t *d)
131162
{
132163
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,
179210
void dns_poller_init(dns_poller_t *d, struct ev_loop *loop,
180211
const char *bootstrap_dns,
181212
int bootstrap_dns_polling_interval,
213+
const char *source_addr,
182214
const char *hostname,
183215
int family, dns_poller_cb cb, void *cb_data) {
184216
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,
207239
d->loop = loop;
208240
d->hostname = hostname;
209241
d->family = family;
242+
set_bootstrap_source_addr(d->ares, source_addr, family);
210243
d->cb = cb;
211244
d->polling_interval = bootstrap_dns_polling_interval;
212245
d->request_ongoing = 0;

src/dns_poller.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ typedef struct {
3737
// provided ev_loop. `bootstrap_dns` is a comma-separated list of DNS servers to
3838
// use for the lookup `hostname` every `interval_seconds`. For each successful
3939
// lookup, `cb` will be called with the resolved address.
40+
// `source_addr` optionally binds bootstrap DNS lookups to a specific IP.
4041
// `family` should be AF_INET for IPv4 or AF_UNSPEC for both IPv4 and IPv6.
4142
//
4243
// Note: hostname *not* copied. It should remain valid until
4344
// dns_poller_cleanup called.
4445
void dns_poller_init(dns_poller_t *d, struct ev_loop *loop,
4546
const char *bootstrap_dns,
4647
int bootstrap_dns_polling_interval,
48+
const char *source_addr,
4749
const char *hostname,
4850
int family, dns_poller_cb cb, void *cb_data);
4951

src/main.c

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,8 @@ int main(int argc, char *argv[]) {
423423
if (hostname_from_url(opt.resolver_url, hostname, sizeof(hostname))) {
424424
app.using_dns_poller = 1;
425425
dns_poller_init(&dns_poller, loop, opt.bootstrap_dns,
426-
opt.bootstrap_dns_polling_interval, hostname,
426+
opt.bootstrap_dns_polling_interval, opt.source_addr,
427+
hostname,
427428
opt.ipv4 ? AF_INET : AF_UNSPEC,
428429
dns_poll_cb, &app);
429430
ILOG("DNS polling initialized for '%s'", hostname);

src/options.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ void options_show_usage(int __attribute__((unused)) argc, char **argv) {
254254
printf(" supports it (http, https, socks4a, socks5h), otherwise\n");
255255
printf(" initial DNS resolution will still be done via the\n");
256256
printf(" bootstrap DNS servers.\n");
257-
printf(" -S source_addr Source IPv4/v6 address for outbound HTTPS connections.\n");
257+
printf(" -S source_addr Source IPv4/v6 address for outbound HTTPS and bootstrap DNS.\n");
258258
printf(" (Default: system default)\n");
259259
printf(" -x Use HTTP/1.1 instead of HTTP/2. Useful with broken\n"
260260
" or limited builds of libcurl.\n");

tests/docker/Dockerfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
FROM ubuntu:24.04
2+
3+
# Install all build and test dependencies in one layer
4+
RUN apt-get update && \
5+
apt-get install -y --no-install-recommends \
6+
iproute2 \
7+
cmake \
8+
build-essential \
9+
libcurl4-openssl-dev \
10+
libc-ares-dev \
11+
libev-dev \
12+
libsystemd-dev \
13+
python3 \
14+
python3-pip \
15+
python3-venv \
16+
dnsutils \
17+
valgrind \
18+
&& rm -rf /var/lib/apt/lists/*
19+
20+
# Install Robot Framework
21+
RUN pip3 install --break-system-packages robotframework
22+
23+
WORKDIR /src
24+
25+
# Default command: build and run tests
26+
# Symlink needed because Robot test expects binary at project root
27+
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"]

tests/docker/run_all_tests.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Docker-based test runner for https_dns_proxy
5+
#
6+
# When to use:
7+
# - Full regression testing before commits/PRs
8+
# - CI/CD pipelines
9+
# - Developing on macOS (proxy uses Linux-specific syscalls like accept4, MSG_MORE)
10+
#
11+
# Runtime: ~2-3 minutes
12+
13+
docker_bin="${DOCKER_BIN:-docker}"
14+
if ! command -v "$docker_bin" >/dev/null 2>&1; then
15+
echo "docker not found; set DOCKER_BIN or install Docker." >&2
16+
exit 1
17+
fi
18+
19+
image="https_dns_proxy_test:latest"
20+
21+
echo "==> Building Docker test image..."
22+
"$docker_bin" build -t "$image" -f tests/docker/Dockerfile . -q
23+
24+
echo "==> Running tests..."
25+
"$docker_bin" run --rm \
26+
--dns 1.1.1.1 --dns 8.8.8.8 \
27+
-v "$PWD":/src "$image"

tests/robot/functional_tests.robot

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,47 @@ Truncate UDP Impossible
203203
... Verify Truncation txtfill4096.test.dnscheck.tools 4096 12 100 ANSWER: 0
204204

205205
Source Address Binding
206-
[Documentation] Test source address binding with -S flag
206+
[Documentation] Test -S flag binds both HTTPS and bootstrap DNS to source address
207+
[Tags] bootstrap
208+
207209
${eth0_ip} = Run ip -4 addr show eth0 | grep inet | awk '{print $2}' | cut -d/ -f1 | tr -d '\\n'
208-
Start Proxy -S ${eth0_ip}
210+
211+
# Use explicit resolver hostname to force bootstrap DNS resolution
212+
Start Proxy -S ${eth0_ip} -b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query
213+
214+
# Wait for bootstrap DNS to complete
215+
Sleep 2s
216+
217+
# Verify source address binding was applied
209218
Set To Dictionary ${expected_logs} Using source address=1
219+
220+
# Verify bootstrap DNS completed successfully
221+
Set To Dictionary ${expected_logs} Received new DNS server IP=1
222+
223+
# Verify no bootstrap DNS failures
224+
Append To List ${error_logs} DNS lookup of 'dns.google' failed
225+
226+
# Verify proxy works (HTTPS connection uses source binding)
210227
Run Dig
228+
229+
Source Address Binding IPv6 With IPv4 Only Mode
230+
[Documentation] Test that IPv6 source address with -4 flag logs warning
231+
[Tags] bootstrap validation
232+
233+
# Start proxy with IPv6 address in IPv4-only mode (-4 flag)
234+
Start Proxy -4 -S ::1 -b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query
235+
Sleep 1s
236+
237+
# Verify warning is logged about address family mismatch
238+
Set To Dictionary ${expected_logs} Bootstrap source address '::1' is IPv6, but IPv4-only mode is set=1
239+
240+
Source Address Binding Invalid Address
241+
[Documentation] Test that invalid source address logs warning
242+
[Tags] bootstrap validation
243+
244+
# Start proxy with invalid IP address
245+
Start Proxy -S not-an-ip-address -b 1.1.1.1,8.8.8.8 -r https://dns.google/dns-query
246+
Sleep 1s
247+
248+
# Verify warning is logged about invalid address
249+
Set To Dictionary ${expected_logs} Bootstrap source address 'not-an-ip-address' is not a valid IP literal=1

0 commit comments

Comments
 (0)