diff --git a/.gitignore b/.gitignore index 5ef9ec7..06cfa97 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ *.x86_64 *.hex dohd +ns2dohd/ns2dohd test/dohd_base64_test # Debug files diff --git a/Makefile b/Makefile index eff0c91..333e6b2 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,26 @@ export VERSION := 0.8 +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/sbin +MANDIR ?= $(PREFIX)/share/man + build: make -C src + make -C ns2dohd debug: make -C src debug + make -C ns2dohd debug dmalloc: make -C src dmalloc asan: make -C src asan + make -C ns2dohd asan clean: make -C src clean + make -C ns2dohd clean make -C test clean docker-build: @@ -72,5 +80,20 @@ check-flame: site: npx docsify-cli serve ./docs +install: build + install -d $(DESTDIR)$(BINDIR) + install -m 0755 src/dohd $(DESTDIR)$(BINDIR)/dohd + install -m 0755 ns2dohd/ns2dohd $(DESTDIR)$(BINDIR)/ns2dohd + install -d $(DESTDIR)$(MANDIR)/man8 + install -m 0644 man/dohd.8 $(DESTDIR)$(MANDIR)/man8/dohd.8 + install -m 0644 man/ns2dohd.8 $(DESTDIR)$(MANDIR)/man8/ns2dohd.8 + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/dohd + rm -f $(DESTDIR)$(BINDIR)/ns2dohd + rm -f $(DESTDIR)$(MANDIR)/man8/dohd.8 + rm -f $(DESTDIR)$(MANDIR)/man8/ns2dohd.8 + .PHONY: build debug dmalloc asan clean docker-build docker-build-alpine docker-run \ - check check-asan check-integration check-valgrind check-flame site + check check-asan check-integration check-valgrind check-flame site \ + install uninstall diff --git a/README.md b/README.md index 167a0cb..d534e68 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ 🔧 Configuration + 🌐 ns2dohd + 😍 Acknowledgements 💼 License @@ -40,7 +42,14 @@ option: `--enable-tls13` or simply `--enable-all`). 2. Type `make`. -3. Executable will be found in `./src/dohd` +3. Executable will be found in `./src/dohd`. + `ns2dohd` executable will be found in `./ns2dohd/ns2dohd`. + +4. Optional install step for binaries and manpages: + +``` +sudo make install +``` *** # 🎮 Quick start @@ -101,6 +110,16 @@ Usage: dohd -c cert -k key [-p port] [-d dnsserver] [-F] [-u user] [-V] [-v] [-h - '-u *user*' - drop root privileges after binding to the TCP port by switching user (mandatory when running as root) - '-F' - runs dohd in foreground (instead of creating a daemon) +*** +# 🌐 ns2dohd + +`ns2dohd` is a companion daemon that accepts plain DNS requests on localhost and forwards them to a DoH endpoint. + +- Project README: [`ns2dohd/README.md`](ns2dohd/README.md) +- Manpage: `man ns2dohd` +- To route system DNS through `ns2dohd`, set `nameserver 127.0.0.1` in `/etc/resolv.conf` or set `127.0.0.1` as primary DNS in NetworkManager. +- Run `ns2dohd` as root in daemon mode and drop privileges with `-u`. + *** # 😍 Acknowledgements @@ -132,4 +151,3 @@ You should have received a copy of the GNU Affero General Public License along with dohd. If not, see . Dohd is licensed under the terms of GNU Affero General Public License (GNU AGPL). See COPYING for details. - diff --git a/man/dohd.8 b/man/dohd.8 new file mode 100644 index 0000000..3ffd3b4 --- /dev/null +++ b/man/dohd.8 @@ -0,0 +1,75 @@ +.TH DOHD 8 "February 2026" "dohd 0.8" "System Manager's Manual" +.SH NAME +\fBdohd\fR \- minimalist DNS-over-HTTPS daemon +.SH SYNOPSIS +.B dohd +\-c +.I cert +\-k +.I key +[ +.B \-p +.I port +] [ +.B \-d +.I dnsserver +] [ +.B \-u +.I user +] [ +.B \-F +] [ +.B \-v +] [ +.B \-V +] [ +.B \-h +] +.SH DESCRIPTION +.B dohd +listens for incoming DoH requests over TLS and forwards DNS payloads to one or more classic DNS resolvers over UDP. +.SH OPTIONS +.TP +.BI \-c " cert" +TLS certificate chain file (PEM). Mandatory. +.TP +.BI \-k " key" +TLS private key file (PEM). Mandatory. +.TP +.BI \-p " port" +Listen port for DoH service. Default: 8053. +.TP +.BI \-d " dnsserver" +Upstream DNS resolver IP address. Can be repeated to add multiple resolvers. +.TP +.BI \-u " user" +Drop privileges to this user after binding sockets (when started as root). +.TP +.B \-F +Run in foreground (do not daemonize). +.TP +.B \-v +Verbose logging. +.TP +.B \-V +Print version and exit. +.TP +.B \-h +Print help and exit. +.SH EXAMPLES +Run in foreground on port 8053 and forward to 8.8.8.8: +.PP +.nf + dohd -c /etc/test.crt -k /etc/test.key -p 8053 -d 8.8.8.8 -F +.fi +.SH FILES +.TP +.I /etc/test.crt +Example TLS certificate. +.TP +.I /etc/test.key +Example TLS private key. +.SH SEE ALSO +.BR ns2dohd (8) +.SH LICENSE +GNU AGPLv3. diff --git a/man/ns2dohd.8 b/man/ns2dohd.8 new file mode 100644 index 0000000..3b34c48 --- /dev/null +++ b/man/ns2dohd.8 @@ -0,0 +1,79 @@ +.TH NS2DOHD 8 "February 2026" "dohd 0.8" "System Manager's Manual" +.SH NAME +\fBns2dohd\fR \- local DNS to DNS-over-HTTPS forwarding daemon +.SH SYNOPSIS +.B ns2dohd +\-d +.I doh_url +[ +.B \-p +.I port +] [ +.B \-u +.I user +] [ +.B \-r +.I resolver_ip +] [ +.B \-A +.I cafile +] [ +.B \-F +] [ +.B \-v +] [ +.B \-V +] [ +.B \-h +] +.SH DESCRIPTION +.B ns2dohd +listens on local UDP DNS (default 127.0.0.1:53), receives classic DNS requests from the host, and forwards them to a DoH endpoint over HTTP/2 using wolfSSL and nghttp2. +.PP +To avoid resolver recursion when the local host itself uses 127.0.0.1 as nameserver, +.B ns2dohd +uses a separate bootstrap DNS resolver (default 1.1.1.1) to resolve the DoH endpoint hostname. +.SH OPTIONS +.TP +.BI \-d " doh_url" +DoH endpoint URL (https://...). Mandatory. +.TP +.BI \-p " port" +Local UDP listen port. Default: 53. +.TP +.BI \-u " user" +Drop privileges to this user after binding sockets (when started as root). +.TP +.BI \-r " resolver_ip" +Bootstrap DNS resolver IP used for resolving the DoH endpoint host. Default: 1.1.1.1. +.TP +.BI \-A " cafile" +CA bundle file in PEM format. +.TP +.B \-F +Run in foreground (do not daemonize). +.TP +.B \-v +Verbose logging. +.TP +.B \-V +Print version and exit. +.TP +.B \-h +Print help and exit. +.SH EXAMPLES +Run as local DNS daemon and drop privileges to user \fB_dohd\fR: +.PP +.nf + ns2dohd -d https://dns.dyne.org/dns-query -u _dohd +.fi +.PP +Use a custom bootstrap resolver: +.PP +.nf + ns2dohd -d https://dns.dyne.org/dns-query -r 9.9.9.9 -u _dohd +.fi +.SH SEE ALSO +.BR dohd (8) +.SH LICENSE +GNU AGPLv3. diff --git a/ns2dohd/Makefile b/ns2dohd/Makefile new file mode 100644 index 0000000..4fea116 --- /dev/null +++ b/ns2dohd/Makefile @@ -0,0 +1,20 @@ +CC=gcc + +CFLAGS := -Wall -Wextra -Wno-sign-compare -DVERSION=\"${VERSION}\" -fPIE +LDFLAGS := -lwolfssl -lrt -lm -lnghttp2 + +all: CFLAGS += -O3 +all: ns2dohd + +debug: CFLAGS += -ggdb -O0 +debug: ns2dohd + +asan: CFLAGS += -fsanitize=address +asan: LDFLAGS += -fsanitize=address +asan: ns2dohd + +ns2dohd: ns2dohd.o + $(CC) -o $@ $^ $(LDFLAGS) + +clean: + rm -f *.o ns2dohd diff --git a/ns2dohd/README.md b/ns2dohd/README.md new file mode 100644 index 0000000..80ebc1c --- /dev/null +++ b/ns2dohd/README.md @@ -0,0 +1,102 @@ +![dohd logo](https://raw.githubusercontent.com/dyne/dohd/master/docs/dohd.png) + +

+ + Dyne.org + +

+ +

+ 💾 Install + + 🎮 Quick start + + 🔧 Configuration + + 💼 License +

+ +`ns2dohd` is a local DNS-to-DoH gateway daemon. +It listens on `127.0.0.1:53` (by default), accepts classic DNS requests, and forwards them to a trusted DoH endpoint using wolfSSL + nghttp2. + +*** +# 💾 Install + +Build requirements: `gcc` or `clang`, `make`, `wolfSSL`, `nghttp2`. + +1. Build binaries: + +```bash +make build +``` + +2. Install binaries and manpages (default prefix `/usr/local`): + +```bash +sudo make install +``` + +You can override installation paths, for example: + +```bash +sudo make install PREFIX=/usr +``` + +*** +# 🎮 Quick start + +Run as root to bind port `53`, then drop privileges using `-u`: + +```bash +ns2dohd -d https://dns.dyne.org/dns-query -u nobody +``` + +`ns2dohd` daemonizes by default. Use `-F` to stay in foreground. + +Common options: + +- `-d ` DoH endpoint URL (mandatory) +- `-p ` local UDP port (default: `53`) +- `-u ` drop privileges after bind +- `-r ` bootstrap resolver used to resolve the DoH endpoint host (default: `1.1.1.1`) +- `-A ` custom CA bundle +- `-F` foreground mode +- `-v` verbose logs + +See full options with: + +```bash +ns2dohd -h +man ns2dohd +``` + +*** +# 🔧 Configuration + +To use `ns2dohd` as your host DNS resolver, configure your system DNS to point to localhost. + +## /etc/resolv.conf + +Set: + +```conf +nameserver 127.0.0.1 +``` + +## NetworkManager + +Set `127.0.0.1` as the primary DNS server in your active network profile, then reconnect. + +After either configuration, keep `ns2dohd` running as a background daemon as root (with `-u` recommended). + +Notes: + +- `ns2dohd` uses a separate bootstrap resolver (default `1.1.1.1`) for resolving the DoH endpoint hostname, avoiding resolver recursion. +- Change bootstrap resolver with `-r`, for example `-r 9.9.9.9`. + +*** +# 💼 License + +This is free software distributed under the GNU Affero General Public License (AGPLv3). + +Author: Dyne.org Foundation `` diff --git a/ns2dohd/ns2dohd.c b/ns2dohd/ns2dohd.c new file mode 100644 index 0000000..c345a71 --- /dev/null +++ b/ns2dohd/ns2dohd.c @@ -0,0 +1,1237 @@ +/* + * ns2dohd.c + * + * This is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, as + * published by the free Software Foundation. + * + * ns2dohd is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with ns2dohd. If not, see . + * + * Author: Dyne.org Foundation + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NS2DOHD_PORT 53 +#define DNS_BUFFER_MAXSIZE 4096 +#define DNS_HEADER_MIN 12 +#define TLS_IO_TIMEOUT_SEC 5 +#define DOH_EXCHANGE_TIMEOUT_SEC 20 +#define DEFAULT_CA_BUNDLE "/etc/ssl/certs/ca-certificates.crt" +#define DEFAULT_BOOTSTRAP_DNS_IP "1.1.1.1" +#define DEFAULT_BOOTSTRAP_DNS_PORT 53 +#define DNS_TIMEOUT_SEC 2 + +#define DOH_ERR LOG_EMERG +#define DOH_WARN LOG_WARNING +#define DOH_NOTICE LOG_NOTICE +#define DOH_INFO LOG_INFO +#define DOH_DEBUG LOG_DEBUG + +#define MAKE_NV(K, V) \ + (nghttp2_nv){ \ + (uint8_t *)K, (uint8_t *)V, strlen(K), strlen(V), \ + NGHTTP2_NV_FLAG_NONE \ + } + +struct doh_upstream { + char host[256]; + char authority[320]; + char path[512]; + char port[8]; +}; + +struct doh_exchange { + const uint8_t *query; + size_t query_len; + size_t query_off; + uint8_t *response; + size_t response_cap; + size_t response_len; + int status_code; + int stream_id; + int stream_closed; + uint32_t stream_error_code; + int failed; +}; + +struct h2_conn { + WOLFSSL *ssl; + struct doh_exchange *xchg; +}; + +struct ns2dohd_stats { + uint64_t queries; + uint64_t replies; + uint64_t errors; + uint64_t servfail_replies; +}; + +static volatile sig_atomic_t run = 1; +static int dohprint_loglevel = LOG_NOTICE; +static int dohprint_syslog = -1; +static struct ns2dohd_stats stats = {}; +static struct sockaddr_storage bootstrap_resolver = {}; +static socklen_t bootstrap_resolver_len = 0; +static void dohprint(int lvl, const char *fmt, ...); + +static int parse_bootstrap_resolver(const char *s, + struct sockaddr_storage *out, socklen_t *out_len) +{ + struct sockaddr_in *sin = (struct sockaddr_in *)out; + struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)out; + struct in_addr a4; + struct in6_addr a6; + + memset(out, 0, sizeof(*out)); + + if (inet_pton(AF_INET, s, &a4) == 1) { + sin->sin_family = AF_INET; + sin->sin_port = htons(DEFAULT_BOOTSTRAP_DNS_PORT); + sin->sin_addr = a4; + *out_len = sizeof(*sin); + return 0; + } + if (inet_pton(AF_INET6, s, &a6) == 1) { + sin6->sin6_family = AF_INET6; + sin6->sin6_port = htons(DEFAULT_BOOTSTRAP_DNS_PORT); + sin6->sin6_addr = a6; + *out_len = sizeof(*sin6); + return 0; + } + + return -1; +} + +static int drop_privileges(const char *user) +{ + struct passwd *pw; + + if (!user) + return 0; + + if (getuid() != 0) { + dohprint(DOH_WARN, "not running as root: '-u' ignored"); + return 0; + } + + pw = getpwnam(user); + if (!pw) { + dohprint(DOH_ERR, "cannot find user '%s'", user); + return -1; + } + + if (setgid(pw->pw_gid) != 0 || setuid(pw->pw_uid) != 0) { + dohprint(DOH_ERR, "cannot drop privileges to '%s'", user); + return -1; + } + return 0; +} + +static void dohprint_init(int fg, int level) +{ + if (fg) + dohprint_syslog = 0; + else + dohprint_syslog = 1; + + if (level > DOH_DEBUG) + level = DOH_DEBUG; + if (level < DOH_ERR) + level = DOH_ERR; + dohprint_loglevel = level; + + if (!fg) + openlog("ns2dohd", LOG_PID, LOG_DAEMON); +} + +static void dohprint(int lvl, const char *fmt, ...) +{ + va_list ap; + + if (dohprint_syslog) { + va_start(ap, fmt); + vsyslog(lvl, fmt, ap); + va_end(ap); + return; + } + + if (lvl <= dohprint_loglevel) { + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fprintf(stderr, "\n"); + } +} + +static void usage(const char *name) +{ + fprintf(stderr, "%s, local DNS to DoH forwarder daemon.\n", name); + fprintf(stderr, "License: AGPL\n"); + fprintf(stderr, "Usage: %s -d https://host/path [-p port] [-u user] [-r resolver] [-A cafile] [-F] [-v] [-V] [-h]\n", name); + fprintf(stderr, "\t'-d': DoH upstream URL (mandatory, https only)\n"); + fprintf(stderr, "\t'-p': local UDP listen port (default: 53)\n"); + fprintf(stderr, "\t'-u': user to switch to after binding (root only)\n"); + fprintf(stderr, "\t'-r': bootstrap DNS resolver IP (default: 1.1.1.1)\n"); + fprintf(stderr, "\t'-A': CA certificate bundle file (PEM)\n"); + fprintf(stderr, "\t'-F': run in foreground\n"); + fprintf(stderr, "\t'-v': verbose logging\n"); + fprintf(stderr, "\t'-V': show version\n"); + fprintf(stderr, "\t'-h': help\n"); +} + +static void handle_sigint(int sig) +{ + (void)sig; + run = 0; +} + +static void handle_sigusr1(int sig) +{ + (void)sig; + dohprint(DOH_NOTICE, "ns2dohd v. %s stats", VERSION); + dohprint(DOH_NOTICE, "queries=%lu replies=%lu errors=%lu servfail=%lu", + stats.queries, stats.replies, stats.errors, stats.servfail_replies); +} + +static int parse_port(const char *s, uint16_t *out) +{ + char *end = NULL; + long p; + + errno = 0; + p = strtol(s, &end, 10); + if (errno || !end || *end != '\0' || p < 1 || p > 65535) + return -1; + *out = (uint16_t)p; + return 0; +} + +static int parse_doh_url(const char *url, struct doh_upstream *up) +{ + const char *prefix = "https://"; + const char *p; + const char *slash; + const char *auth_end; + size_t auth_len; + char auth[320]; + + memset(up, 0, sizeof(*up)); + + if (!url || strncmp(url, prefix, strlen(prefix)) != 0) + return -1; + + p = url + strlen(prefix); + if (*p == '\0') + return -1; + + slash = strchr(p, '/'); + if (slash) { + size_t path_len = strlen(slash); + if (path_len >= sizeof(up->path)) + return -1; + memcpy(up->path, slash, path_len + 1); + auth_end = slash; + } else { + strcpy(up->path, "/"); + auth_end = p + strlen(p); + } + + auth_len = (size_t)(auth_end - p); + if (auth_len == 0 || auth_len >= sizeof(auth)) + return -1; + memcpy(auth, p, auth_len); + auth[auth_len] = '\0'; + + strcpy(up->port, "443"); + + if (auth[0] == '[') { + char *rb = strchr(auth, ']'); + char *port = NULL; + if (!rb) + return -1; + *rb = '\0'; + if (strlen(auth + 1) >= sizeof(up->host)) + return -1; + strcpy(up->host, auth + 1); + if (*(rb + 1) != '\0') { + if (*(rb + 1) != ':') + return -1; + port = rb + 2; + if (*port == '\0' || strlen(port) >= sizeof(up->port)) + return -1; + strcpy(up->port, port); + } + } else { + char *colon = strrchr(auth, ':'); + if (colon && strchr(auth, ':') == colon) { + *colon = '\0'; + if (*(colon + 1) == '\0' || strlen(colon + 1) >= sizeof(up->port)) + return -1; + strcpy(up->port, colon + 1); + } + + if (strlen(auth) == 0 || strlen(auth) >= sizeof(up->host)) + return -1; + strcpy(up->host, auth); + } + + if (strcmp(up->port, "443") == 0) { + if (auth[0] == '[') + snprintf(up->authority, sizeof(up->authority), "[%s]", up->host); + else + snprintf(up->authority, sizeof(up->authority), "%s", up->host); + } else { + if (auth[0] == '[') + snprintf(up->authority, sizeof(up->authority), "[%s]:%s", up->host, up->port); + else + snprintf(up->authority, sizeof(up->authority), "%s:%s", up->host, up->port); + } + + return 0; +} + +static int make_servfail(const uint8_t *in, size_t inlen, uint8_t *out, size_t *outlen) +{ + uint16_t flags; + + if (inlen < DNS_HEADER_MIN) + return -1; + + if (inlen > DNS_BUFFER_MAXSIZE) + inlen = DNS_BUFFER_MAXSIZE; + + memcpy(out, in, inlen); + + flags = ((uint16_t)in[2] << 8) | in[3]; + flags &= 0x0100; + flags |= 0x8082; + + out[2] = (uint8_t)(flags >> 8); + out[3] = (uint8_t)(flags & 0xff); + + out[6] = 0; + out[7] = 0; + out[8] = 0; + out[9] = 0; + out[10] = 0; + out[11] = 0; + + *outlen = inlen; + return 0; +} + +static unsigned long long monotonic_ms(void) +{ + struct timespec ts; + + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) + return 0; + + return ((unsigned long long)ts.tv_sec * 1000ULL) + + ((unsigned long long)ts.tv_nsec / 1000000ULL); +} + +static uint16_t rd_u16(const uint8_t *p) +{ + return (uint16_t)((p[0] << 8) | p[1]); +} + +static int dns_skip_name(const uint8_t *msg, size_t mlen, size_t off, size_t *next_off) +{ + size_t steps = 0; + + while (off < mlen && steps < 256) { + uint8_t c = msg[off]; + if ((c & 0xC0) == 0xC0) { + if ((off + 1) >= mlen) + return -1; + *next_off = off + 2; + return 0; + } + if (c == 0) { + *next_off = off + 1; + return 0; + } + off++; + if ((off + c) > mlen) + return -1; + off += c; + steps++; + } + + return -1; +} + +static int dns_put_qname(const char *host, uint8_t *out, size_t out_cap, size_t *out_len) +{ + const char *p = host; + size_t used = 0; + + while (*p) { + const char *dot = strchr(p, '.'); + size_t len = dot ? (size_t)(dot - p) : strlen(p); + + if (len == 0 || len > 63 || (used + 1 + len + 1) > out_cap) + return -1; + + out[used++] = (uint8_t)len; + memcpy(out + used, p, len); + used += len; + + if (!dot) + break; + p = dot + 1; + } + + if ((used + 1) > out_cap) + return -1; + out[used++] = 0; + *out_len = used; + return 0; +} + +static int resolve_host_bootstrap_type(const char *host, uint16_t qtype, + struct sockaddr_storage *out, socklen_t *out_len) +{ + uint8_t qbuf[512]; + uint8_t rbuf[512]; + size_t qname_len = 0; + size_t off; + uint16_t id; + int sd; + struct sockaddr_storage from; + socklen_t fromlen = sizeof(from); + ssize_t n; + struct timeval tv; + int i; + + memset(qbuf, 0, sizeof(qbuf)); + if (bootstrap_resolver_len == 0) + return -1; + + sd = socket(bootstrap_resolver.ss_family, SOCK_DGRAM, 0); + if (sd < 0) + return -1; + + tv.tv_sec = DNS_TIMEOUT_SEC; + tv.tv_usec = 0; + if (setsockopt(sd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0 || + setsockopt(sd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) < 0) { + close(sd); + return -1; + } + + id = (uint16_t)(((unsigned)getpid() ^ (unsigned)time(NULL)) & 0xFFFF); + qbuf[0] = (uint8_t)(id >> 8); + qbuf[1] = (uint8_t)(id & 0xFF); + qbuf[2] = 0x01; /* RD */ + qbuf[5] = 0x01; /* QDCOUNT */ + + if (dns_put_qname(host, qbuf + DNS_HEADER_MIN, sizeof(qbuf) - DNS_HEADER_MIN, &qname_len) != 0) { + close(sd); + return -1; + } + + off = DNS_HEADER_MIN + qname_len; + if ((off + 4) > sizeof(qbuf)) { + close(sd); + return -1; + } + qbuf[off++] = (uint8_t)(qtype >> 8); + qbuf[off++] = (uint8_t)(qtype & 0xFF); + qbuf[off++] = 0x00; /* IN */ + qbuf[off++] = 0x01; + + if (sendto(sd, qbuf, off, 0, + (struct sockaddr *)&bootstrap_resolver, bootstrap_resolver_len) < 0) { + close(sd); + return -1; + } + + n = recvfrom(sd, rbuf, sizeof(rbuf), 0, (struct sockaddr *)&from, &fromlen); + close(sd); + if (n < DNS_HEADER_MIN) + return -1; + + if (rd_u16(rbuf) != id) + return -1; + if ((rbuf[2] & 0x80) == 0) + return -1; + if ((rbuf[3] & 0x0F) != 0) + return -1; + if (rd_u16(rbuf + 4) != 1) + return -1; + + off = DNS_HEADER_MIN; + if (dns_skip_name(rbuf, (size_t)n, off, &off) != 0) + return -1; + if ((off + 4) > (size_t)n) + return -1; + off += 4; + + for (i = 0; i < (int)rd_u16(rbuf + 6); i++) { + uint16_t atype, aclass, rdlen; + + if (dns_skip_name(rbuf, (size_t)n, off, &off) != 0) + return -1; + if ((off + 10) > (size_t)n) + return -1; + + atype = rd_u16(rbuf + off); + aclass = rd_u16(rbuf + off + 2); + rdlen = rd_u16(rbuf + off + 8); + off += 10; + + if ((off + rdlen) > (size_t)n) + return -1; + + if (aclass == 1 && atype == qtype) { + if (qtype == 1 && rdlen == 4) { + struct sockaddr_in *sin = (struct sockaddr_in *)out; + memset(out, 0, sizeof(*out)); + sin->sin_family = AF_INET; + memcpy(&sin->sin_addr, rbuf + off, 4); + *out_len = sizeof(*sin); + return 0; + } + if (qtype == 28 && rdlen == 16) { + struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)out; + memset(out, 0, sizeof(*out)); + sin6->sin6_family = AF_INET6; + memcpy(&sin6->sin6_addr, rbuf + off, 16); + *out_len = sizeof(*sin6); + return 0; + } + } + + off += rdlen; + } + + return -1; +} + +static int resolve_host_bootstrap(const char *host, struct sockaddr_storage *out, socklen_t *out_len) +{ + struct in_addr a4; + struct in6_addr a6; + + if (inet_pton(AF_INET, host, &a4) == 1) { + struct sockaddr_in *sin = (struct sockaddr_in *)out; + memset(out, 0, sizeof(*out)); + sin->sin_family = AF_INET; + sin->sin_addr = a4; + *out_len = sizeof(*sin); + return 0; + } + if (inet_pton(AF_INET6, host, &a6) == 1) { + struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)out; + memset(out, 0, sizeof(*out)); + sin6->sin6_family = AF_INET6; + sin6->sin6_addr = a6; + *out_len = sizeof(*sin6); + return 0; + } + + if (resolve_host_bootstrap_type(host, 1, out, out_len) == 0) + return 0; + if (resolve_host_bootstrap_type(host, 28, out, out_len) == 0) + return 0; + return -1; +} + +static int tcp_connect_timeout(const struct doh_upstream *up) +{ + struct sockaddr_storage resolved; + socklen_t resolved_len = 0; + int fd = -1; + struct timeval tv; + uint16_t port; + char *end = NULL; + long p; + + p = strtol(up->port, &end, 10); + if (!end || *end != '\0' || p < 1 || p > 65535) + return -1; + port = (uint16_t)p; + + if (resolve_host_bootstrap(up->host, &resolved, &resolved_len) != 0) + return -1; + + tv.tv_sec = TLS_IO_TIMEOUT_SEC; + tv.tv_usec = 0; + + if (resolved.ss_family == AF_INET) { + ((struct sockaddr_in *)&resolved)->sin_port = htons(port); + } else if (resolved.ss_family == AF_INET6) { + ((struct sockaddr_in6 *)&resolved)->sin6_port = htons(port); + } else { + return -1; + } + + fd = socket(resolved.ss_family, SOCK_STREAM, 0); + if (fd < 0) + return -1; + + if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0 || + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) < 0) { + close(fd); + return -1; + } + + if (connect(fd, (struct sockaddr *)&resolved, resolved_len) != 0) { + close(fd); + return -1; + } + return fd; +} + +static ssize_t h2_read_cb(nghttp2_session *session, + int32_t stream_id, + uint8_t *buf, + size_t length, + uint32_t *data_flags, + nghttp2_data_source *source, + void *user_data) +{ + struct doh_exchange *x = (struct doh_exchange *)source->ptr; + size_t left; + + (void)session; + (void)stream_id; + (void)user_data; + + if (!x || x->query_off > x->query_len) + return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; + + left = x->query_len - x->query_off; + if (left == 0) { + *data_flags = NGHTTP2_DATA_FLAG_EOF; + return 0; + } + + if (left > length) + left = length; + + memcpy(buf, x->query + x->query_off, left); + x->query_off += left; + + if (x->query_off == x->query_len) + *data_flags = NGHTTP2_DATA_FLAG_EOF; + + return (ssize_t)left; +} + +static ssize_t h2_send_cb(nghttp2_session *session, + const uint8_t *data, + size_t length, + int flags, + void *user_data) +{ + struct h2_conn *conn = (struct h2_conn *)user_data; + int ret; + + (void)session; + (void)flags; + + ret = wolfSSL_write(conn->ssl, data, (int)length); + if (ret > 0) + return ret; + + conn->xchg->failed = 1; + return NGHTTP2_ERR_CALLBACK_FAILURE; +} + +static int h2_on_header_cb(nghttp2_session *session, + const nghttp2_frame *frame, + const uint8_t *name, + size_t namelen, + const uint8_t *value, + size_t valuelen, + uint8_t flags, + void *user_data) +{ + struct h2_conn *conn = (struct h2_conn *)user_data; + + (void)session; + (void)flags; + + if (!conn || !conn->xchg) + return 0; + + if (frame->hd.stream_id != conn->xchg->stream_id) + return 0; + + if (frame->hd.type != NGHTTP2_HEADERS || + frame->headers.cat != NGHTTP2_HCAT_RESPONSE) + return 0; + + if (namelen == 7 && memcmp(name, ":status", 7) == 0) { + char scode[4]; + size_t cp = valuelen; + if (cp >= sizeof(scode)) + cp = sizeof(scode) - 1; + memcpy(scode, value, cp); + scode[cp] = '\0'; + conn->xchg->status_code = atoi(scode); + } + + return 0; +} + +static int h2_on_data_chunk_recv_cb(nghttp2_session *session, + uint8_t flags, + int32_t stream_id, + const uint8_t *data, + size_t len, + void *user_data) +{ + struct h2_conn *conn = (struct h2_conn *)user_data; + struct doh_exchange *x; + + (void)session; + (void)flags; + + if (!conn || !conn->xchg) + return 0; + + x = conn->xchg; + if (stream_id != x->stream_id) + return 0; + + if ((x->response_len + len) > x->response_cap) { + x->failed = 1; + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + + memcpy(x->response + x->response_len, data, len); + x->response_len += len; + + return 0; +} + +static int h2_on_stream_close_cb(nghttp2_session *session, + int32_t stream_id, + uint32_t error_code, + void *user_data) +{ + struct h2_conn *conn = (struct h2_conn *)user_data; + + (void)session; + (void)error_code; + + if (conn && conn->xchg && stream_id == conn->xchg->stream_id) { + conn->xchg->stream_error_code = error_code; + conn->xchg->stream_closed = 1; + } + + return 0; +} + +static int doh_query_roundtrip(WOLFSSL_CTX *wctx, + const struct doh_upstream *up, + const uint8_t *query, + size_t query_len, + uint8_t *reply, + size_t *reply_len) +{ + int fd = -1; + int ret; + WOLFSSL *ssl = NULL; + char *proto = NULL; + unsigned short proto_len = 0; + nghttp2_session_callbacks *cbs = NULL; + nghttp2_session *session = NULL; + nghttp2_data_provider data_prd; + nghttp2_settings_entry iv[1] = { + { NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS, 100 } + }; + nghttp2_nv nva[7]; + struct doh_exchange x = {}; + struct h2_conn conn = {}; + uint8_t tlsbuf[DNS_BUFFER_MAXSIZE]; + char clength[32]; + char errbuf[160]; + unsigned long long deadline_ms; + + *reply_len = 0; + deadline_ms = monotonic_ms() + (DOH_EXCHANGE_TIMEOUT_SEC * 1000ULL); + + fd = tcp_connect_timeout(up); + if (fd < 0) { + dohprint(DOH_DEBUG, "DoH connection failed"); + return -1; + } + + ssl = wolfSSL_new(wctx); + if (!ssl) { + dohprint(DOH_DEBUG, "wolfSSL_new failed"); + goto fail; + } + + wolfSSL_set_fd(ssl, fd); + wolfSSL_UseSNI(ssl, WOLFSSL_SNI_HOST_NAME, up->host, (unsigned short)strlen(up->host)); + + if (wolfSSL_UseALPN(ssl, "h2", 2, WOLFSSL_ALPN_FAILED_ON_MISMATCH) != SSL_SUCCESS) { + dohprint(DOH_DEBUG, "wolfSSL_UseALPN(h2) failed"); + goto fail; + } + + ret = wolfSSL_connect(ssl); + if (ret != SSL_SUCCESS) { + int werr = wolfSSL_get_error(ssl, ret); + wolfSSL_ERR_error_string_n((unsigned long)werr, errbuf, sizeof(errbuf)); + dohprint(DOH_DEBUG, "TLS handshake failed (err=%d: %s)", werr, errbuf); + goto fail; + } + + if (!wolfSSL_ALPN_GetProtocol(ssl, &proto, &proto_len)) { + dohprint(DOH_DEBUG, "ALPN protocol not negotiated"); + goto fail; + } + if (!proto || proto_len != 2 || memcmp(proto, "h2", 2) != 0) { + dohprint(DOH_DEBUG, "ALPN mismatch (proto_len=%u)", (unsigned)proto_len); + goto fail; + } + + if (nghttp2_session_callbacks_new(&cbs) != 0) { + dohprint(DOH_DEBUG, "nghttp2_session_callbacks_new failed"); + goto fail; + } + + nghttp2_session_callbacks_set_send_callback(cbs, h2_send_cb); + nghttp2_session_callbacks_set_on_header_callback(cbs, h2_on_header_cb); + nghttp2_session_callbacks_set_on_data_chunk_recv_callback(cbs, h2_on_data_chunk_recv_cb); + nghttp2_session_callbacks_set_on_stream_close_callback(cbs, h2_on_stream_close_cb); + + conn.ssl = ssl; + conn.xchg = &x; + + if (nghttp2_session_client_new(&session, cbs, &conn) != 0) { + dohprint(DOH_DEBUG, "nghttp2_session_client_new failed"); + goto fail; + } + + if (nghttp2_submit_settings(session, NGHTTP2_FLAG_NONE, iv, 1) != 0) { + dohprint(DOH_DEBUG, "nghttp2_submit_settings failed"); + goto fail; + } + + x.query = query; + x.query_len = query_len; + x.query_off = 0; + x.response = reply; + x.response_cap = DNS_BUFFER_MAXSIZE; + x.response_len = 0; + x.status_code = 0; + + snprintf(clength, sizeof(clength), "%lu", (unsigned long)query_len); + + nva[0] = MAKE_NV(":method", "POST"); + nva[1] = MAKE_NV(":scheme", "https"); + nva[2] = MAKE_NV(":authority", up->authority); + nva[3] = MAKE_NV(":path", up->path); + nva[4] = MAKE_NV("content-type", "application/dns-message"); + nva[5] = MAKE_NV("accept", "application/dns-message"); + nva[6] = MAKE_NV("content-length", clength); + + data_prd.source.ptr = &x; + data_prd.read_callback = h2_read_cb; + + x.stream_id = nghttp2_submit_request(session, NULL, nva, 7, &data_prd, &x); + if (x.stream_id < 0) { + dohprint(DOH_DEBUG, "nghttp2_submit_request failed (%d)", x.stream_id); + goto fail; + } + + while (!x.stream_closed && !x.failed) { + unsigned long long now_ms = monotonic_ms(); + if (now_ms > 0 && now_ms >= deadline_ms) { + dohprint(DOH_DEBUG, "DoH exchange timed out"); + x.failed = 1; + break; + } + + ret = nghttp2_session_send(session); + if (ret != 0) { + dohprint(DOH_DEBUG, "nghttp2_session_send failed (%s)", nghttp2_strerror(ret)); + x.failed = 1; + break; + } + + ret = wolfSSL_read(ssl, tlsbuf, sizeof(tlsbuf)); + if (ret <= 0) { + int werr = wolfSSL_get_error(ssl, ret); + if (werr == WOLFSSL_ERROR_WANT_READ || werr == WOLFSSL_ERROR_WANT_WRITE) + continue; + dohprint(DOH_DEBUG, "wolfSSL_read failed (err=%d)", werr); + x.failed = 1; + break; + } + + ret = (int)nghttp2_session_mem_recv(session, tlsbuf, (size_t)ret); + if (ret < 0) { + dohprint(DOH_DEBUG, "nghttp2_session_mem_recv failed (%s)", nghttp2_strerror(ret)); + x.failed = 1; + break; + } + } + + if (x.failed || x.status_code != 200 || x.response_len < DNS_HEADER_MIN) { + dohprint(DOH_DEBUG, "DoH exchange invalid: failed=%d status=%d len=%lu stream_err=%u", + x.failed, x.status_code, (unsigned long)x.response_len, + (unsigned)x.stream_error_code); + goto fail; + } + + *reply_len = x.response_len; + + nghttp2_session_del(session); + nghttp2_session_callbacks_del(cbs); + wolfSSL_free(ssl); + close(fd); + return 0; + +fail: + if (session) + nghttp2_session_del(session); + if (cbs) + nghttp2_session_callbacks_del(cbs); + if (ssl) + wolfSSL_free(ssl); + if (fd >= 0) + close(fd); + return -1; +} + +int main(int argc, char *argv[]) +{ + struct doh_upstream upstream; + WOLFSSL_CTX *wctx = NULL; + char *url = NULL; + char *cafile = NULL; + char *user = NULL; + char *resolver_ip = NULL; + uint16_t port = NS2DOHD_PORT; + int foreground = 0; + int option_idx = 0; + int c; + int lfd = -1; + struct sockaddr_in addr; + uint8_t dns_req[DNS_BUFFER_MAXSIZE]; + uint8_t dns_rep[DNS_BUFFER_MAXSIZE]; + struct sockaddr_storage cliaddr; + socklen_t cliaddr_len; + ssize_t n; + int log_level = DOH_WARN; + struct option long_options[] = { + {"help", 0, 0, 'h'}, + {"version", 0, 0, 'V'}, + {"doh-url", 1, 0, 'd'}, + {"port", 1, 0, 'p'}, + {"user", 1, 0, 'u'}, + {"resolver", 1, 0, 'r'}, + {"ca-file", 1, 0, 'A'}, + {"verbose", 0, 0, 'v'}, + {"do-not-fork", 0, 0, 'F'}, + {NULL, 0, 0, '\0'} + }; + struct sigaction sa = {}; + + while (1) { + c = getopt_long(argc, argv, "hVd:p:u:r:A:vF", long_options, &option_idx); + if (c < 0) + break; + + switch (c) { + case 'h': + usage(argv[0]); + return 0; + case 'V': + fprintf(stderr, "%s, %s\n", argv[0], VERSION); + return 0; + case 'd': + free(url); + url = strdup(optarg); + break; + case 'p': + if (parse_port(optarg, &port) != 0) { + fprintf(stderr, "Invalid port: %s\n", optarg); + return 2; + } + break; + case 'u': + free(user); + user = strdup(optarg); + break; + case 'r': + free(resolver_ip); + resolver_ip = strdup(optarg); + break; + case 'A': + free(cafile); + cafile = strdup(optarg); + break; + case 'v': + log_level = DOH_DEBUG; + break; + case 'F': + foreground = 1; + break; + default: + usage(argv[0]); + return 2; + } + } + + if (optind < argc) { + usage(argv[0]); + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 2; + } + + if (!url) { + usage(argv[0]); + free(user); + free(resolver_ip); + free(cafile); + return 2; + } + + if (parse_doh_url(url, &upstream) != 0) { + fprintf(stderr, "Invalid DoH URL: %s\n", url); + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 2; + } + + if (!resolver_ip) + resolver_ip = strdup(DEFAULT_BOOTSTRAP_DNS_IP); + if (!resolver_ip || + parse_bootstrap_resolver(resolver_ip, &bootstrap_resolver, &bootstrap_resolver_len) != 0) { + fprintf(stderr, "Invalid bootstrap resolver IP: %s\n", + resolver_ip ? resolver_ip : "(null)"); + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 2; + } + + if (!foreground) { + int pid = fork(); + if (pid < 0) + return 1; + if (pid > 0) { + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 0; + } + + pid = fork(); + if (pid < 0) + return 1; + if (pid > 0) { + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 0; + } + + setsid(); + close(STDIN_FILENO); + close(STDOUT_FILENO); + close(STDERR_FILENO); + } + + sa.sa_handler = handle_sigint; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGINT, &sa, NULL); + sigaction(SIGTERM, &sa, NULL); + + sa.sa_handler = handle_sigusr1; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGUSR1, &sa, NULL); + + sa.sa_handler = SIG_IGN; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGPIPE, &sa, NULL); + + dohprint_init(foreground, log_level); + dohprint(DOH_NOTICE, "ns2dohd v. %s", VERSION); + dohprint(DOH_NOTICE, "DNS-to-DoH forwarding enabled"); + + wolfSSL_Init(); + wctx = wolfSSL_CTX_new(wolfTLS_client_method()); + if (!wctx) { + dohprint(DOH_ERR, "wolfSSL context initialization failed"); + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 1; + } + + wolfSSL_CTX_set_verify(wctx, WOLFSSL_VERIFY_PEER, NULL); + if (cafile) { + if (wolfSSL_CTX_load_verify_locations(wctx, cafile, NULL) != SSL_SUCCESS) { + dohprint(DOH_ERR, "cannot load CA bundle file"); + wolfSSL_CTX_free(wctx); + free(user); + free(resolver_ip); + free(url); + free(cafile); + return 1; + } + dohprint(DOH_NOTICE, "using custom CA bundle"); + } else { + if (wolfSSL_CTX_load_verify_locations(wctx, DEFAULT_CA_BUNDLE, NULL) == SSL_SUCCESS) { + dohprint(DOH_NOTICE, "using default CA bundle"); + } else if (wolfSSL_CTX_load_system_CA_certs(wctx) == SSL_SUCCESS || + wolfSSL_CTX_set_default_verify_paths(wctx) == SSL_SUCCESS) { + dohprint(DOH_NOTICE, "using system default CA certificates"); + } else { + dohprint(DOH_ERR, "cannot load CA certificates; use -A "); + wolfSSL_CTX_free(wctx); + free(user); + free(resolver_ip); + free(url); + free(cafile); + return 1; + } + } + + lfd = socket(AF_INET, SOCK_DGRAM, 0); + if (lfd < 0) { + dohprint(DOH_ERR, "cannot create UDP socket: %s", strerror(errno)); + wolfSSL_CTX_free(wctx); + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 1; + } + + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + dohprint(DOH_ERR, "cannot bind 127.0.0.1:%u: %s", port, strerror(errno)); + close(lfd); + wolfSSL_CTX_free(wctx); + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 1; + } + + if (drop_privileges(user) != 0) { + close(lfd); + wolfSSL_CTX_free(wctx); + free(user); + free(resolver_ip); + free(cafile); + free(url); + return 1; + } + + dohprint(DOH_NOTICE, "listening on 127.0.0.1:%u", port); + + while (run) { + size_t reply_len = 0; + + cliaddr_len = sizeof(cliaddr); + n = recvfrom(lfd, dns_req, sizeof(dns_req), 0, + (struct sockaddr *)&cliaddr, &cliaddr_len); + if (n < 0) { + if (errno == EINTR) + continue; + stats.errors++; + dohprint(DOH_WARN, "recvfrom error: %s", strerror(errno)); + continue; + } + + stats.queries++; + if (n < DNS_HEADER_MIN) { + stats.errors++; + continue; + } + + if (doh_query_roundtrip(wctx, &upstream, dns_req, (size_t)n, dns_rep, &reply_len) != 0) { + if (strcmp(upstream.path, "/") == 0) { + struct doh_upstream alt = upstream; + strcpy(alt.path, "/dns-query"); + dohprint(DOH_DEBUG, "retrying DoH query on fallback path"); + if (doh_query_roundtrip(wctx, &alt, dns_req, (size_t)n, dns_rep, &reply_len) == 0) + goto reply_send; + } + stats.errors++; + if (make_servfail(dns_req, (size_t)n, dns_rep, &reply_len) == 0) + stats.servfail_replies++; + else + continue; + dohprint(DOH_WARN, "upstream DoH request failed, returned SERVFAIL"); + } + +reply_send: + if (sendto(lfd, dns_rep, reply_len, 0, + (struct sockaddr *)&cliaddr, cliaddr_len) < 0) { + stats.errors++; + dohprint(DOH_WARN, "sendto error: %s", strerror(errno)); + continue; + } + + stats.replies++; + } + + close(lfd); + wolfSSL_CTX_free(wctx); + wolfSSL_Cleanup(); + free(url); + free(user); + free(resolver_ip); + free(cafile); + + if (!foreground) + closelog(); + + return 0; +}