├── .gitignore ├── .npmignore ├── .travis.yml ├── Dockerfile ├── Makefile ├── README.md ├── docker-compose.yml ├── docs ├── api │ └── README.md ├── cli │ └── README.md ├── docker-compose-pihole │ └── README.md ├── docker │ └── README.md ├── launchd │ └── README.md ├── pihole │ └── README.md ├── raspbian │ └── README.md └── systemd │ └── README.md ├── dohnut-overview.png ├── package.json ├── source ├── bin.js ├── cli.js ├── getPopularDomains.js ├── master.js └── worker.js └── test └── start-query-stop.js /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.travis.yml 3 | !.gitignore 4 | qemu-* 5 | etc-* 6 | top-1m* 7 | coverage/ 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | Dockerfile 3 | test/ 4 | docs/ 5 | *.png 6 | qemu-* 7 | etc-* 8 | top-1m* 9 | Makefile 10 | *.yml 11 | coverage/ 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # the following values must be set in https://travis-ci.com//settings 2 | # - DOCKER_REPO (eg. myrepo/myapp) 3 | # - DOCKER_USERNAME 4 | # - DOCKER_PASSWORD 5 | 6 | services: docker 7 | language: go 8 | 9 | branches: 10 | only: 11 | - master 12 | - /^v\d+\.\d+\.\d+.*$/ 13 | 14 | env: 15 | - ARCH=amd64 16 | - ARCH=arm32v6 17 | - ARCH=arm64v8 18 | 19 | before_script: 20 | - docker run --rm --privileged multiarch/qemu-user-static:register --reset 21 | 22 | script: 23 | - make build ARCH=${ARCH} DOCKER_REPO=${DOCKER_REPO} 24 | - make test ARCH=${ARCH} DOCKER_REPO=${DOCKER_REPO} 25 | 26 | after_success: 27 | # the following test will make sure we deploy only when a new git tag is pushed 28 | # otherwise it will deploy on every push 29 | - if [[ ${TRAVIS_TAG} =~ ^v[0-9]+\.[0-9]+\.[0-9]+.*$ ]] ; then echo deploying tag ${TRAVIS_TAG} ; else exit 0 ; fi 30 | - echo ${DOCKER_PASSWORD} | docker login -u "${DOCKER_USERNAME}" --password-stdin 31 | - make push ARCH=${ARCH} DOCKER_REPO=${DOCKER_REPO} 32 | - go get github.com/estesp/manifest-tool 33 | - make manifest DOCKER_REPO=${DOCKER_REPO} 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH=amd64 2 | ARG NODE_VERSION=current 3 | 4 | FROM alpine:3.9.2 as qemu 5 | 6 | RUN apk add --no-cache curl 7 | 8 | ARG QEMU_VERSION=4.2.0-7 9 | 10 | # https://github.com/hadolint/hadolint/wiki/DL4006 11 | SHELL ["/bin/ash", "-o", "pipefail", "-c"] 12 | 13 | RUN curl -fsSL https://github.com/multiarch/qemu-user-static/releases/download/v${QEMU_VERSION}/qemu-arm-static.tar.gz | tar zxvf - -C /usr/bin 14 | RUN curl -fsSL https://github.com/multiarch/qemu-user-static/releases/download/v${QEMU_VERSION}/qemu-aarch64-static.tar.gz | tar zxvf - -C /usr/bin 15 | 16 | RUN chmod +x /usr/bin/qemu-* 17 | 18 | # ---------------------------------------------------------------------------- 19 | 20 | FROM ${ARCH}/node:${NODE_VERSION}-alpine as build 21 | 22 | # copy qemu binaries used for cross-compiling 23 | COPY --from=qemu /usr/bin/qemu-* /usr/bin/ 24 | 25 | RUN apk add --no-cache \ 26 | alpine-sdk \ 27 | curl \ 28 | drill \ 29 | git \ 30 | net-tools \ 31 | python 32 | 33 | WORKDIR /app 34 | 35 | COPY package.json ./ 36 | COPY source/ ./source 37 | 38 | RUN yarn install --ignore-optional --production 39 | 40 | # ---------------------------------------------------------------------------- 41 | 42 | FROM ${ARCH}/node:${NODE_VERSION}-alpine 43 | 44 | ARG BUILD_DATE 45 | ARG BUILD_VERSION 46 | ARG VCS_REF 47 | 48 | LABEL org.label-schema.schema-version="1.0" 49 | LABEL org.label-schema.name="commonshost/dohnut" 50 | LABEL org.label-schema.description="Dohnut is a DNS to DNS-over-HTTPS (DoH) proxy server" 51 | LABEL org.label-schema.url="https://help.commons.host/dohnut/" 52 | LABEL org.label-schema.vcs-url="https://github.com/commonshost/dohnut" 53 | LABEL org.label-schema.docker.cmd="docker run -p 53:53/tcp -p 53:53/udp commonshost/dohnut --listen 0.0.0.0:53 --doh commonshost" 54 | LABEL org.label-schema.build-date="${BUILD_DATE}" 55 | LABEL org.label-schema.version="${BUILD_VERSION}" 56 | LABEL org.label-schema.vcs-ref="${VCS_REF}" 57 | 58 | WORKDIR /etc/dohnut 59 | VOLUME /etc/dohnut 60 | 61 | ENV NODE_ENV production 62 | 63 | COPY --from=build /app /app 64 | 65 | ENTRYPOINT [ "node", "/app/source/bin.js" ] 66 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # override these values at runtime as desired 2 | # eg. make build ARCH=arm32v6 BUILD_OPTIONS=--no-cache 3 | ARCH := amd64 4 | DOCKER_REPO := commonshost/dohnut 5 | BUILD_OPTIONS += 6 | 7 | # ARCH to GOARCH mapping (don't change these) 8 | # supported ARCH values: https://github.com/docker-library/official-images#architectures-other-than-amd64 9 | # supported GOARCH values: https://golang.org/doc/install/source#environment 10 | ifeq "${ARCH}" "amd64" 11 | GOARCH := amd64 12 | GOARM := 13 | endif 14 | 15 | ifeq "${ARCH}" "arm32v6" 16 | GOARCH := arm 17 | GOARM := 6 18 | endif 19 | 20 | ifeq "${ARCH}" "arm64v8" 21 | GOARCH := arm64 22 | GOARM := 23 | endif 24 | 25 | # these values are used for container labels at build time 26 | BUILD_DATE := $(strip $(shell docker run --rm busybox date -u +'%Y-%m-%dT%H:%M:%SZ')) 27 | BUILD_VERSION := $(strip $(shell git describe --tags --always --dirty)) 28 | VCS_REF := $(strip $(shell git rev-parse --short HEAD)) 29 | VCS_TAG := $(strip $(shell git describe --abbrev=0 --tags)) 30 | DOCKER_TAG := ${VCS_TAG}-${GOARCH} 31 | 32 | .DEFAULT_GOAL := build 33 | 34 | .EXPORT_ALL_VARIABLES: 35 | 36 | ## -- General -- 37 | 38 | ## Display this help message 39 | .PHONY: help 40 | help: 41 | @awk '{ \ 42 | if ($$0 ~ /^.PHONY: [a-zA-Z\-\_0-9]+$$/) { \ 43 | helpCommand = substr($$0, index($$0, ":") + 2); \ 44 | if (helpMessage) { \ 45 | printf "\033[36m%-20s\033[0m %s\n", \ 46 | helpCommand, helpMessage; \ 47 | helpMessage = ""; \ 48 | } \ 49 | } else if ($$0 ~ /^[a-zA-Z\-\_0-9.]+:/) { \ 50 | helpCommand = substr($$0, 0, index($$0, ":")); \ 51 | if (helpMessage) { \ 52 | printf "\033[36m%-20s\033[0m %s\n", \ 53 | helpCommand, helpMessage; \ 54 | helpMessage = ""; \ 55 | } \ 56 | } else if ($$0 ~ /^##/) { \ 57 | if (helpMessage) { \ 58 | helpMessage = helpMessage"\n "substr($$0, 3); \ 59 | } else { \ 60 | helpMessage = substr($$0, 3); \ 61 | } \ 62 | } else { \ 63 | if (helpMessage) { \ 64 | print "\n "helpMessage"\n" \ 65 | } \ 66 | helpMessage = ""; \ 67 | } \ 68 | }' \ 69 | $(MAKEFILE_LIST) 70 | 71 | .PHONY: qemu-user-static 72 | qemu-user-static: 73 | @docker run --rm --privileged multiarch/qemu-user-static:register --reset 74 | 75 | qemu-arm-static: 76 | wget -q https://github.com/multiarch/qemu-user-static/releases/download/v4.0.0/qemu-arm-static \ 77 | && chmod +x qemu-arm-static 78 | 79 | qemu-aarch64-static: 80 | wget -q https://github.com/multiarch/qemu-user-static/releases/download/v4.0.0/qemu-aarch64-static \ 81 | && chmod +x qemu-aarch64-static 82 | 83 | ## -- Parameters -- 84 | 85 | ## Select a target architecture (optional): amd64|arm32v6|arm64v8 86 | ## eg. make ARCH=arm32v6 87 | ## 88 | .PHONY: ARCH 89 | 90 | ## Provide additional docker build flags (optional) 91 | ## eg. make BUILD_OPTIONS=--no-cache 92 | ## 93 | .PHONY: BUILD_OPTIONS 94 | 95 | ## Override default docker repo (optional) 96 | ## eg. make DOCKER_REPO=myrepo/myapp 97 | .PHONY: DOCKER_REPO 98 | 99 | ## -- Docker -- 100 | 101 | ## Build, test, and push the image in one step 102 | ## eg. make release [ARCH=] [BUILD_OPTIONS=] [DOCKER_REPO=] 103 | ## 104 | .PHONY: release 105 | release: build test push 106 | 107 | ## Build an image for the selected platform 108 | ## eg. make build [ARCH=] [BUILD_OPTIONS=] [DOCKER_REPO=] 109 | ## 110 | .PHONY: build 111 | build: qemu-user-static 112 | @docker build ${BUILD_OPTIONS} \ 113 | --build-arg ARCH \ 114 | --build-arg BUILD_VERSION \ 115 | --build-arg BUILD_DATE \ 116 | --build-arg VCS_REF \ 117 | --tag ${DOCKER_REPO}:${DOCKER_TAG} . 118 | 119 | ## Test an image by running it locally and requesting DNS lookups 120 | ## eg. make test [ARCH=] [DOCKER_REPO=] 121 | ## 122 | .PHONY: test 123 | test: qemu-user-static qemu-arm-static qemu-aarch64-static 124 | $(eval CONTAINER_ID=$(shell docker run --rm -d \ 125 | -v "$(CURDIR)/qemu-arm-static:/usr/bin/qemu-arm-static" \ 126 | -v "$(CURDIR)/qemu-aarch64-static:/usr/bin/qemu-aarch64-static" \ 127 | -p 53:53/tcp -p 53:53/udp ${DOCKER_REPO}:${DOCKER_TAG} --listen 0.0.0.0:53 --doh commonshost)) 128 | dig sigok.verteiltesysteme.net @127.0.0.1 | grep NOERROR || (docker stop ${CONTAINER_ID}; exit 1) 129 | dig sigfail.verteiltesysteme.net @127.0.0.1 | grep SERVFAIL || (docker stop ${CONTAINER_ID}; exit 1) 130 | @docker stop ${CONTAINER_ID} 131 | 132 | ## Push an image to the configured docker repo 133 | ## eg. make push [ARCH=] [DOCKER_REPO=] 134 | ## 135 | .PHONY: push 136 | push: 137 | @docker push ${DOCKER_REPO}:${DOCKER_TAG} 138 | 139 | ## Create and push a multi-arch manifest list 140 | ## eg. make manifest [DOCKER_REPO=] 141 | ## 142 | .PHONY: manifest 143 | manifest: 144 | @manifest-tool push from-args \ 145 | --platforms linux/amd64,linux/arm,linux/arm64 \ 146 | --template ${DOCKER_REPO}:${VCS_TAG}-ARCH \ 147 | --target ${DOCKER_REPO}:${VCS_TAG} \ 148 | --ignore-missing 149 | @manifest-tool push from-args \ 150 | --platforms linux/amd64,linux/arm,linux/arm64 \ 151 | --template ${DOCKER_REPO}:${VCS_TAG}-ARCH \ 152 | --target ${DOCKER_REPO}:latest \ 153 | --ignore-missing 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dohnut 2 | 3 | [![](https://img.shields.io/badge/dynamic/json.svg?color=blue&label=Docker%20Hub&query=pull_count&suffix=%20pulls&url=https%3A%2F%2Fhub.docker.com%2Fv2%2Frepositories%2Fcommonshost%2Fdohnut%2F)](https://hub.docker.com/r/commonshost/dohnut) 4 | 5 | Dohnut is a DNS to DNS-over-HTTPS (DoH) proxy server. Dohnut improves the performance, security, and privacy of your DNS traffic. 6 | 7 | https://help.commons.host/dohnut/ 8 | 9 | Dohnut works with any open standard ([RFC8484](https://tools.ietf.org/html/rfc8484)) compliant DoH provider, including the [Commons Host](https://commons.host) DoH service and [many others](http://dns-channel.github.io/#recsrv). 10 | 11 | ![Dohnut overview diagram](./dohnut-overview.png) 12 | 13 | ## Features 14 | 15 | **High Performance** Auto-select the fastest DoH resolver. Continuously adapts to network and service conditions by monitoring the round-trip-tip of the DoH connection using HTTP/2 PING frames. 16 | 17 | **High Availability** Allows using multiple DoH resolvers at once to provide automatic failover in case a service is unavailable. 18 | 19 | **Zero Overhead** - Network traffic does not go through Dohnut so there is no performance penalty. Only the DNS queries (very little bandwidth) are proxied. 20 | 21 | **Lightweight** - Multi-threaded architecture for fast performance on low-power devices like single board computers. Designed for Raspberry Pi and Odroid but compatible with anything that can run Node.js. 22 | 23 | **Full Encryption** - DoH encrypts all DNS queries inside a secure HTTP/2 connection. This protects DNS lookups against snooping at your local network router or ISP. 24 | 25 | **Connection Sharding** - Spread queries across multiple DoH resolvers for improved privacy. This reduces the amount of information a single DoH service can collect. 26 | 27 | **Query Spoofing** - Mask your DNS queries using fake DNS queries. Uses several randomisation techniques and samples from a public list of the top 1 million domains. 28 | 29 | **User Agent Spoofing** - Avoid tracking at the HTTP level using fake browser identifiers. Randomly chosen from a public list of real-world browser data. 30 | 31 | ## Usage 32 | 33 | Dohnut is lightweight and cross-platform. Dohnut can operate standalone or with other DNS tools like [Pi-hole](https://pi-hole.net). 34 | 35 | Dohnut can be used in several ways: 36 | 37 | - [Command line interface](./docs/cli) 38 | - [Docker: container image](./docs/docker) 39 | - [Linux: managed by systemd](./docs/systemd) 40 | - [macOS: managed by launchd](./docs/launchd) 41 | - [Pi-hole: upstream DNS server](./docs/pihole) 42 | - [Docker Compose with Pi-hole: multi-container service](./docs/docker-compose-pihole) 43 | 44 | This example launches Dohnut on your local machine to accept DNS connections and proxy them to the Commons Host DNS over HTTPS (DoH) service. See the [command line interface](./docs/cli) reference for more options. 45 | 46 | Run using Docker: 47 | 48 | ```shell 49 | $ docker run --publish 53:53/udp commonshost/dohnut --listen 0.0.0.0:53 --doh commonshost --bootstrap 9.9.9.9 50 | ``` 51 | 52 | ... or run using Node.js: 53 | 54 | ```shell 55 | $ sudo npx dohnut --listen 127.0.0.1:53 --doh https://commons.host 56 | 57 | Started listening on 127.0.0.1:53 (udp4) 58 | ``` 59 | 60 | Verify by running a DNS lookup against Dohnut. The query is proxied to the DoH service. 61 | 62 | ```shell 63 | $ dig @localhost iana.org 64 | 65 | ; <<>> DiG 9.10.6 <<>> @localhost iana.org 66 | ; (2 servers found) 67 | ;; global options: +cmd 68 | ;; Got answer: 69 | ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 24758 70 | ;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 71 | 72 | ;; OPT PSEUDOSECTION: 73 | ; EDNS: version: 0, flags:; udp: 4096 74 | ;; QUESTION SECTION: 75 | ;iana.org. IN A 76 | 77 | ;; ANSWER SECTION: 78 | iana.org. 3591 IN A 192.0.43.8 79 | 80 | ;; Query time: 4 msec 81 | ;; SERVER: 127.0.0.1#53(127.0.0.1) 82 | ;; MSG SIZE rcvd: 53 83 | ``` 84 | 85 | ## Credits 86 | 87 | Made by [Kenny Shen](https://www.machinesung.com) and [Sebastiaan Deckers](https://twitter.com/sebdeckers) for 🐑 [Commons Host](https://commons.host). 88 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | # See: Dohnut (options passed as environment variables) 5 | # https://help.commons.host/dohnut/cli/#options 6 | dohnut: 7 | container_name: dohnut 8 | image: commonshost/dohnut:latest 9 | # build: . 10 | restart: unless-stopped 11 | networks: 12 | dohnut_pihole: 13 | ipv4_address: 10.0.0.2 14 | environment: 15 | DOHNUT_LISTEN: 10.0.0.2:53000 16 | DOHNUT_BOOTSTRAP: 9.9.9.9 8.8.8.8 1.1.1.1 17 | DOHNUT_DOH: commonshost 18 | DOHNUT_COUNTERMEASURES: spoof-queries spoof-useragent 19 | DOHNUT_CACHE_DIRECTORY: /etc/dohnut 20 | volumes: 21 | - "./etc-dohnut/:/etc/dohnut/" 22 | 23 | # See: Docker Pi-hole 24 | # https://github.com/pi-hole/docker-pi-hole 25 | pihole: 26 | depends_on: 27 | - dohnut 28 | container_name: pihole 29 | image: pihole/pihole:latest 30 | restart: unless-stopped 31 | networks: 32 | dohnut_pihole: 33 | ipv4_address: 10.0.0.3 34 | environment: 35 | # WEBPASSWORD: "set a secure password here or it will be random" 36 | DNS1: 10.0.0.2#53000 37 | DNS2: "no" 38 | dns: 39 | - 127.0.0.1 40 | cap_add: 41 | - NET_ADMIN 42 | volumes: 43 | - "./etc-pihole/:/etc/pihole/" 44 | - "./etc-dnsmasq.d/:/etc/dnsmasq.d/" 45 | ports: 46 | - "53:53/tcp" 47 | - "53:53/udp" 48 | - "67:67/udp" 49 | - "80:80/tcp" 50 | - "443:443/tcp" 51 | 52 | networks: 53 | dohnut_pihole: 54 | driver: bridge 55 | ipam: 56 | config: 57 | - subnet: 10.0.0.0/24 58 | -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | Programmatic use of Dohnut in JavaScript. 4 | 5 | ## Installation 6 | 7 | ```shell 8 | $ npm install dohnut 9 | ``` 10 | 11 | ## `new Dohnut(configuration)` 12 | 13 | ```js 14 | const dohnut = new Dohnut(configuration) 15 | ``` 16 | 17 | The `configuration` object contains all options described in the [CLI](../cli) documentation. However the data structure is not identical to the `--options` file format. Notably the `dns` and `doh` arguments are broken down into explicit parameters. 18 | 19 | ```json 20 | { 21 | "dns": [ 22 | { 23 | "type": "udp4", 24 | "address": "0.0.0.0", 25 | "port": 53 26 | }, 27 | { 28 | "type": "udp6", 29 | "address": "::", 30 | "port": 53 31 | } 32 | ], 33 | "doh": [ 34 | { "uri": "https://commons.host" }, 35 | { "uri": "https://doh.powerdns.org" } 36 | ], 37 | "bootstrap": [ 38 | "1.1.1.1", 39 | "8.8.8.8", 40 | "9.9.9.9" 41 | ], 42 | "countermeasures": [ 43 | "spoof-queries", 44 | "spoof-useragent" 45 | ], 46 | "load-balance": "privacy" 47 | } 48 | ``` 49 | 50 | ## `await dohnut.start()` 51 | 52 | Returns a promise that resolves once the server is ready and listening on all DNS sockets. 53 | 54 | Rejects with an error if anything goes wrong. 55 | 56 | ## `await dohnut.stop()` 57 | 58 | Returns a promise that resolves after gracefully closing the server: all listening DNS sockets, and all outbound HTTP/2 connections. 59 | -------------------------------------------------------------------------------- /docs/cli/README.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | ## Installation 4 | 5 | Try out Dohnut using the `npx` tool that is included with Node.js. This downloads and installs Dohnut to a temporary directory, runs the `dohnut` command, and then cleans up all files without a trace once the process terminates. It always downloads the latest version so this can also be used to auto-update Dohnut as part of long-running service processes. 6 | 7 | ```shell 8 | $ npx dohnut [OPTIONS] 9 | ``` 10 | 11 | Installing Dohnut as a global command is a good way to pin a specific version and minimise startup time. 12 | 13 | ```shell 14 | $ npm install --global dohnut 15 | $ dohnut [OPTIONS] 16 | ``` 17 | 18 | Docker's [`docker` CLI tool](https://docs.docker.com/engine/reference/run/) can automatically install, cache, and run Dohnut on the command line. See [Dohnut with Docker](../docker) for detailed information. 19 | 20 | ```shell 21 | $ docker run [DOCKER_OPTIONS] commonshost/dohnut [DOHNUT_OPTIONS] 22 | ``` 23 | 24 | ## Options 25 | 26 | ### `--doh`, `--upstream`, `--proxy` 27 | 28 | Array of URLs or shortnames of upstream DNS over HTTPS resolvers. 29 | 30 | Queries are distributed randomly over all resolvers. 31 | 32 | Default: `[ "https://commons.host" ]` 33 | 34 | ### `--listen`, `--local`, `-l` 35 | 36 | Array of IPs and ports for the local DNS server. 37 | 38 | Default: `[ "127.0.0.1:53", "[::1]:53" ]` 39 | 40 | ### `--test`, `--validate`, `--configtest` 41 | 42 | Validate the arguments without starting the server. Process exit code `1` indicates failure, or `0` for success. 43 | 44 | Default: `false` 45 | 46 | ### `--load-balance`, `--lb` 47 | 48 | The strategy to use with multiple DoH resolvers. 49 | 50 | Default: `performance` 51 | 52 | #### `--load-balance performance` 53 | 54 | Best performance. Always send DNS queries to the fastest DoH resolver. Continuously monitors the round-trip-time latency to each DoH resolver using HTTP/2 PING frames. 55 | 56 | #### `--load-balance privacy` 57 | 58 | Best privacy. Uniformly distributes DNS queries across all enabled DoH resolvers. 59 | 60 | ### `--countermeasures` 61 | 62 | One or more special tactics to protect your privacy. 63 | 64 | Default: `[]` 65 | 66 | #### `--countermeasures spoof-queries` 67 | 68 | Adds plausible deniability to any legitimate DNS query. Makes it hard for a DoH resolver to profile your DNS queries. 69 | 70 | Whenever a DNS query is proxied, a fake query is also generated. The fake query is for a domain from a public top 1 million DNS domains list, sampled by an exponential distribution. To resist detection, the fake query is sent randomly before, after, with a delay, or not at all. 71 | 72 | #### `--countermeasures spoof-useragent` 73 | 74 | Sends a fake `User-Agent` HTTP header to prevent tracking. Makes it look like every DoH request is by a different browser. Randomly samples actual user agent strings from a public data source of real-world web traffic. 75 | 76 | ### `--bootstrap` 77 | 78 | Default: `[]` 79 | 80 | One or more IPv4 or IPv6 addresses of DNS resolvers. These are used to perform the initial DNS lookup for the DoH URI hostname. 81 | 82 | If this option is not specified, the operating system resolves the DoH URI hostname based on your network settings, typically provided automatically via DHCP or manually configured. This option is used to avoid a loop when Dohnut itself is the DNS resolver of the operating system. 83 | 84 | A possible loop scenario is when Dohnut provides transparent DoH proxying as the upstream DNS server for a [Pi-hole](https://pi-hole.net) service. If the operating system running Dohnut uses the Pi-hole server as its DNS server, a lookup loop is created. To break out of the loop, set the bootstrap option to the IP address of the DNS server of your LAN router, your ISP, or a [public DNS service](https://en.wikipedia.org/wiki/Public_recursive_name_server). 85 | 86 | Notes: 87 | - Only the DoH URI hostname is resolved via the bootstrap DNS lookup. Actual user DNS queries are never exposed. 88 | - DoH bootstrapping is considered failsafe. Tampering during bootstrap by a DNS resolver results in a failed DoH connection. DoH uses HTTP/2 which requires a valid TLS certificate for the DoH URI hostname. No queries are exposed without a secure HTTP/2 connection. 89 | 90 | ### `--datagram-protocol` 91 | 92 | Default: `udp6` 93 | 94 | Sets the protocol to use for local listening UDP sockets when the IP address is not specified. For example if `--listen` is used with only a port number. Or when a socket file descriptor is provided by a service manager like systemd (Linux) or launchd (macOS). 95 | 96 | Set to `udp4` to use IPv4. Set to `udp6` to use IPv6. 97 | 98 | ### `--cache-directory` 99 | 100 | Default: *Current working directory* 101 | 102 | Specifies the path to a directory where data is saved. Speeds up process restarts. 103 | 104 | Only used by the `spoof-queries` feature. By saving a local copy of the popular domain names list, this avoids repeated download and processing. 105 | 106 | ### `--config` 107 | 108 | Path to JSON config file 109 | 110 | The JSON config file options are identical to the CLI options. 111 | 112 | ### `--version` 113 | 114 | Show version number 115 | 116 | ### `--help` 117 | 118 | Show help 119 | 120 | ## Shortnames 121 | 122 | Public resolver names mapped to a DoH URL. Based on the [@commonshost/resolvers](https://gitlab.com/commonshost/resolvers) list. 123 | 124 | - `cleanbrowsing` 125 | - `cloudflare` 126 | - `commonshost` 127 | - `google` 128 | - `keoweon` 129 | - `mozilla` 130 | - `nekomimi` 131 | - `powerdns` 132 | - `quad9` 133 | - `rubyfish` 134 | - `securedns` 135 | 136 | ## Examples 137 | 138 | Only allow localhost connections. Proxy to the Commons Host DoH service. 139 | 140 | --listen 127.0.0.1 ::1 --doh commonshost 141 | 142 | Use a custom resolver 143 | 144 | --doh https://localhost/my-own-resolver 145 | 146 | Multiple DoH service can be used. Shortnames for popular services are supported. 147 | 148 | --doh commonshost cloudflare quad9 cleanbrowsing https://example.com 149 | 150 | Listen on all network interfaces using both IPv6 and IPv4. 151 | 152 | --listen :: 0.0.0.0 153 | 154 | Listen on a non-privileged port (>=1024). 155 | 156 | --listen 8053 157 | 158 | Listen on `127.0.0.1:53` using UDP over IPv4. 159 | 160 | --port 53 --datagram-protocol udp4 161 | 162 | Listen on `[::1]:53` using UDP over IPv6. 163 | 164 | --port 53 --datagram-protocol udp6 165 | 166 | Check the syntax of the URL and IP address arguments. No connections are attempted. 167 | 168 | --test --doh https://example.com --listen 192.168.12.34 169 | 170 | Send queries to one of multiple DoH services at random for increased privacy. 171 | 172 | --load-balance privacy --doh quad9 cloudflare commonshost 173 | 174 | Send queries to the fastest DoH service by measuring ping round-trip-times. 175 | 176 | --load-balance performance --doh quad9 cloudflare commonshost 177 | 178 | Randomly send fake DNS queries as disinformation to deter tracking by resolvers. 179 | 180 | --countermeasures spoof-queries 181 | 182 | Mimic popular web browsers by including a random User-Agent header with each request. Default is no User-Agent header. 183 | 184 | --countermeasures spoof-useragent 185 | 186 | Bypass the operating system DNS settings to resolve the DoH service hostnames. 187 | 188 | --bootstrap 192.168.1.1 1.1.1.1 8.8.8.8 9.9.9.9 189 | 190 | Load options from a JSON file 191 | 192 | --config ~/dohnut-options.json 193 | 194 | ## Environment Variables 195 | 196 | All command line interface options can also be specified as environment variables. 197 | 198 | Environment variable names must be uppercase and begin with the `DOHNUT_` prefix. Hyphens are replaced with `_` underscores. 199 | 200 | Environment variables with multiple values must be space-separated. In Bash the values will need to be quoted, but `.env` configuration files (e.g. Docker Compose, systemd) do not require quotes. 201 | 202 | | Example | Option | Environment Variable | 203 | |-|-|-| 204 | | Single value | `--load-balance performance` | `DOHNUT_LOAD_BALANCE=performance` | 205 | | Multiple values | `--bootstrap 1.1.1.1 8.8.8.8` | `DOHNUT_BOOTSTRAP="1.1.1.1 8.8.8.8"` | 206 | -------------------------------------------------------------------------------- /docs/docker-compose-pihole/README.md: -------------------------------------------------------------------------------- 1 | # Dohnut and Pi-hole with Docker Compose 2 | 3 | ## Usage 4 | 5 | Use [Docker Compose](https://docs.docker.com/compose/) to run Dohnut and [Pi-hole](https://pi-hole.net) side-by-side on the same host. Dohnut encrypts Pi-hole's upstream DNS queries, adds DNS countermeasures, and supports load balancing across multiple DoH providers for better privacy and/or performance. 6 | 7 | This example runs Dohnut and Pi-hole, exposing the DNS resolver on port `53`. It also exposes the Pi-hole web dashboard for monitoring and management at: 8 | 9 | - http://pi.hole/admin/ 10 | 11 | Remember to set the `WEBPASSWORD` to your preferred password for the dashboard. 12 | 13 | Save the YAML file and run both Dohnut and Pi-hole as automatically restarting background services: 14 | 15 | $ docker-compose up --detach 16 | 17 | ## Template 18 | 19 | `./docker-compose.yml` 20 | 21 | ```yaml 22 | version: "3" 23 | 24 | services: 25 | # See: Dohnut (options passed as environment variables) 26 | # https://help.commons.host/dohnut/cli/#options 27 | dohnut: 28 | container_name: dohnut 29 | image: commonshost/dohnut:latest 30 | restart: unless-stopped 31 | networks: 32 | dohnut_pihole: 33 | ipv4_address: 10.0.0.2 34 | environment: 35 | DOHNUT_LISTEN: 10.0.0.2:53000 36 | DOHNUT_BOOTSTRAP: 9.9.9.9 8.8.8.8 1.1.1.1 37 | DOHNUT_DOH: commonshost 38 | DOHNUT_COUNTERMEASURES: spoof-queries spoof-useragent 39 | DOHNUT_CACHE_DIRECTORY: /etc/dohnut 40 | volumes: 41 | - "./etc-dohnut/:/etc/dohnut/" 42 | 43 | # See: Docker Pi-hole 44 | # https://github.com/pi-hole/docker-pi-hole 45 | pihole: 46 | depends_on: 47 | - dohnut 48 | container_name: pihole 49 | image: pihole/pihole:latest 50 | restart: unless-stopped 51 | networks: 52 | dohnut_pihole: 53 | ipv4_address: 10.0.0.3 54 | environment: 55 | # WEBPASSWORD: "set a secure password here or it will be random" 56 | DNS1: 10.0.0.2#53000 57 | DNS2: "no" 58 | dns: 59 | - 127.0.0.1 60 | cap_add: 61 | - NET_ADMIN 62 | volumes: 63 | - "./etc-pihole/:/etc/pihole/" 64 | - "./etc-dnsmasq.d/:/etc/dnsmasq.d/" 65 | ports: 66 | - "53:53/tcp" 67 | - "53:53/udp" 68 | - "67:67/udp" 69 | - "80:80/tcp" 70 | - "443:443/tcp" 71 | 72 | networks: 73 | dohnut_pihole: 74 | driver: bridge 75 | ipam: 76 | config: 77 | - subnet: 10.0.0.0/24 78 | ``` 79 | 80 | ## See Also 81 | 82 | - [Docker Compose `up` command reference](https://docs.docker.com/compose/reference/up/) 83 | - [Docker Pi-hole documentation](https://github.com/pi-hole/docker-pi-hole) 84 | -------------------------------------------------------------------------------- /docs/docker/README.md: -------------------------------------------------------------------------------- 1 | # Dohnut with Docker 2 | 3 | Use Docker to run Dohnut in a container. Multi-arch Docker container images are provided for: ARMv6, ARMv7, ARMv8, and x86-64 (Intel/AMD). 4 | 5 | Docker image: [`commonshost/dohnut`](https://hub.docker.com/r/commonshost/dohnut) 6 | 7 | ```shell 8 | $ docker run [DOCKER_OPTIONS] commonshost/dohnut [DOHNUT_OPTIONS] 9 | ``` 10 | 11 | Any options before the image name `commonshost/dohnut` are for Docker. Any options after the image name are for Dohnut. 12 | 13 | ## Example 14 | 15 | Run forever as a background service, listen on port `53/udp` on all network interfaces, and DNS proxy queries to Commons Host DoH. 16 | 17 | ```shell 18 | $ docker run --detach --restart unless-stopped --publish 53:53/udp --name dohnut commonshost/dohnut --listen "[::]:53" --doh commonshost --bootstrap 9.9.9.9 19 | ``` 20 | 21 | Then check the logs, stop running the service, and remove the container. 22 | 23 | ``` 24 | $ docker logs --follow dohnut 25 | ``` 26 | 27 | To stop and remove the container: 28 | 29 | ``` 30 | $ docker stop dohnut 31 | $ docker rm dohnut 32 | ``` 33 | 34 | Or, using [Docker Compose](https://docs.docker.com/compose/): 35 | 36 | ```yaml 37 | version: "3" 38 | 39 | services: 40 | dohnut: 41 | container_name: dohnut 42 | image: commonshost/dohnut:latest 43 | environment: 44 | TZ: "Europe/Brussels" 45 | DOHNUT_LISTEN: 127.0.0.1:53 46 | DOHNUT_BOOTSTRAP: 1.1.1.1 47 | DOHNUT_DOH: commonshost cloudflare google 48 | DOHNUT_COUNTERMEASURES: spoof-queries 49 | network_mode: "host" 50 | ``` 51 | 52 | See also: [Dohnut and Pi-hole using Docker Compose](../docker-compose-pihole) 53 | 54 | Test the service by performing a DNS query on the Docker host system. 55 | 56 | $ dig @localhost example.com 57 | 58 | ; <<>> DiG 9.10.6 <<>> @127.0.0.1 example.com 59 | ; (1 server found) 60 | ;; global options: +cmd 61 | ;; Got answer: 62 | ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 32488 63 | ;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 64 | 65 | ;; OPT PSEUDOSECTION: 66 | ; EDNS: version: 0, flags:; udp: 4096 67 | ;; QUESTION SECTION: 68 | ;example.com. IN A 69 | 70 | ;; ANSWER SECTION: 71 | example.com. 86236 IN A 93.184.216.34 72 | 73 | ;; Query time: 13 msec 74 | ;; SERVER: 127.0.0.1#53(127.0.0.1) 75 | ;; WHEN: Tue Feb 19 15:44:53 +08 2019 76 | ;; MSG SIZE rcvd: 56 77 | 78 | ## Dohnut CLI Options 79 | 80 | See the [Dohnut command line interface](../cli) reference. 81 | 82 | ## Docker CLI Options 83 | 84 | See the [Docker reference documentation](https://docs.docker.com/engine/reference/run/) for `docker run` arguments. 85 | 86 | ## Background Service Daemon 87 | 88 | Using the `--detach` or `-d` Docker option to run Dohnut as a background service, aka *daemon*. 89 | 90 | The `--restart unless-stopped` Docker option automatically runs Dohnut when Docker starts (i.e. at system boot), and restart the process if it crashes. 91 | 92 | ## Bootstrapping 93 | 94 | Use the `--bootstrap [DNS server IP address]` for the initial DNS lookup of the DoH resolver. 95 | 96 | Dohnut resolves the DoH service's address using plaintext DNS over UDP, as per the operating system's DNS settings, unless overridden with the `--bootstrap` option. This lookup will fail if Dohnut itself is part of the OS' DNS chain. For example if Dohnut is configured as the upstream resolver to Pi-hole, or used directly as the operating system's DNS server via DHCP or static network configuration. In such cases, a circular DNS dependency is created which prevents Dohnut from connecting to its DoH resolver. 97 | 98 | The bootstrap DNS service is only used for the initial connection to the DoH resolver. All subsequent queries are encrypted and sent directly to the DoH resolver. 99 | 100 | Example error: Without the bootstrap option, Dohnut is unable to resolve the domain `commons.host` which is set as its DoH resolver. 101 | 102 | Worker 1: session error getaddrinfo EAI_AGAIN commons.host commons.host:443 103 | 104 | Solution: Set the `--bootstrap` option to the IP address of a public DNS service, for example `1.1.1.1` (Cloudflare) or `9.9.9.9` (Quad9). 105 | 106 | $ dohnut [...] --bootstrap 9.9.9.9 107 | 108 | ## Networking 109 | 110 | Dohnut runs isolated inside a container so Docker needs to map Dohnut's listening ports to the host's network. 111 | 112 | ```shell 113 | $ docker run --detach --restart unless-stopped --publish 53:53/udp --name=dohnut commonshost/dohnut --listen "[::]:53" --doh commonshost --bootstrap 9.9.9.9 114 | ``` 115 | 116 | Please ensure that Dohnut is only exposed to a private LAN or localhost. Running a public, open DNS resolver exposed to public Internet traffic is strongly discouraged. Plaintext DNS/UDP is a potential source of [traffic amplification in DDoS attacks](https://en.wikipedia.org/wiki/Denial-of-service_attack#Amplification). 117 | 118 | Expose Dohnut on `127.0.0.1` or `0.0.0.0` for localhost-only or all network interfaces respectively. 119 | 120 | DNS uses port `53` by default but one use case of re-mapping to another port is when Dohnut is used as a local proxy for another resolver like `resolved` or [Pi-hole](../pihole). For example to run Dohnut on port `53000` and only be accessible from the local host: 121 | 122 | ```shell 123 | $ docker run --detach --restart unless-stopped --publish 127.0.0.1:53000:53/udp --name=dohnut commonshost/dohnut --listen "[::]:53" --doh commonshost --bootstrap 9.9.9.9 124 | 125 | $ dig @localhost -p 53000 example.com 126 | ``` 127 | -------------------------------------------------------------------------------- /docs/launchd/README.md: -------------------------------------------------------------------------------- 1 | # Dohnut with `launchd` (macOS) 2 | 3 | Run Dohnut as a background process using the macOS `launchd` service manager. 4 | 5 | The `launchd` service manager will handle the network port to accept incoming DNS queries. This lets Dohnut run with restricted permissions of a regular user instead of the `root` account. 6 | 7 | The network connection starts listening automatically at login. Dohnut will be started automatically by `launchd` as soon as a DNS query is received. Dohnut is restarted automatically on crashes. 8 | 9 | ## Install Node.js 10 | 11 | Go to the Node.js website to download and install the **Latest** version of Node.js. 12 | 13 | https://nodejs.org 14 | 15 | Dohnut requires Node.js version 11.4.0 or later. 16 | 17 | ## Install Xcode 18 | 19 | These Apple developer tools are required to install Dohnut on macOS. Installing Xcode can take a while. 20 | 21 | Go to **🍎** > **App Store** > **Search**: Xcode > **Get** or **Install App** 22 | 23 | ## Install Dohnut 24 | 25 | From the Terminal, run: 26 | 27 | $ npm install --global dohnut 28 | 29 | Tip: With some system-wide versions of Node.js, as opposed to per-user version managers like [nvm](https://github.com/creationix/nvm) or [n](https://github.com/tj/n), you may see a permissions error. If that happens, try running the command: 30 | 31 | $ sudo npm install --global --allow-root dohnut 32 | 33 | ## Create a Service File 34 | 35 | Create the `dohnut.plist` service file for `launchd`: 36 | 37 | $ nano ~/Library/LaunchAgents/host.commons.dohnut.plist 38 | 39 | See the [command line interface](../cli) reference to customise the options. 40 | 41 | Be sure to change `USERNAME` to your macOS username. 42 | 43 | Copy, paste, edit, save, exit. 44 | 45 | ```xml 46 | 47 | 49 | 50 | 51 | Label 52 | host.commons.dohnut 53 | ProgramArguments 54 | 55 | 59 | /usr/local/bin/node 60 | 65 | /Users/USERNAME/n/bin/dohnut 66 | 67 | 68 | --doh 69 | commonshost 70 | cleanbrowsing 71 | cloudflare 72 | quad9 73 | 74 | 75 | --bootstrap 76 | 1.1.1.1 77 | 8.8.8.8 78 | 9.9.9.9 79 | 80 | 81 | --datagram-protocol 82 | udp4 83 | 84 | Sockets 85 | 86 | dohnut 87 | 88 | 89 | SockFamily 90 | IPv4 91 | SockProtocol 92 | UDP 93 | SockServiceName 94 | 53 95 | SockType 96 | dgram 97 | 98 | 99 | 100 | 104 | StandardErrorPath 105 | /usr/local/var/log/dohnut/std_error.log 106 | StandardOutPath 107 | /usr/local/var/log/dohnut/std_out.log 108 | 109 | 110 | ``` 111 | 112 | ## Activate the Dohnut service 113 | 114 | First create the logging directory as configured in the `.plist` file above. 115 | 116 | $ mkdir /usr/local/var/log/dohnut 117 | 118 | Then load the Dohnut service to begin listening for DNS queries. 119 | 120 | $ launchctl load ~/Library/LaunchAgents/host.commons.dohnut.plist 121 | 122 | Verify that that the service has been loaded. 123 | 124 | $ launchctl list | grep dohnut 125 | 126 | The first number shows the process ID or `0` if it is not yet running. The second number shows an exit code, which is `0` on success or non-zero on error. 127 | 128 | Check the output and error logs to verify correct operation. 129 | 130 | $ tail -f /usr/local/var/log/dohnut/std_out.log 131 | $ tail -f /usr/local/var/log/dohnut/std_error.log 132 | 133 | To make changes and apply, unload the service and re-load it. 134 | 135 | $ launchctl unload ~/Library/LaunchAgents/host.commons.dohnut.plist 136 | $ launchctl load ~/Library/LaunchAgents/host.commons.dohnut.plist 137 | 138 | ## Configure Network Preferences 139 | 140 | Go to **🍎** > **System Preferences** > **Network** > **Advanced** > **DNS** and set `127.0.0.1` as your DNS server (remove any others). 141 | 142 | ## Verify 143 | 144 | Your first DNS query should activate Dohnut. 145 | 146 | You can trigger a manual query using `dig`. 147 | 148 | ```shell 149 | $ dig @127.0.0.1 iana.org 150 | 151 | ; <<>> DiG 9.10.6 <<>> @127.0.0.1 iana.org 152 | ; (2 servers found) 153 | ;; global options: +cmd 154 | ;; Got answer: 155 | ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 24758 156 | ;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 157 | 158 | ;; OPT PSEUDOSECTION: 159 | ; EDNS: version: 0, flags:; udp: 4096 160 | ;; QUESTION SECTION: 161 | ;iana.org. IN A 162 | 163 | ;; ANSWER SECTION: 164 | iana.org. 3591 IN A 192.0.43.8 165 | 166 | ;; Query time: 4 msec 167 | ;; SERVER: 127.0.0.1#53(127.0.0.1) 168 | ;; MSG SIZE rcvd: 53 169 | ``` 170 | -------------------------------------------------------------------------------- /docs/pihole/README.md: -------------------------------------------------------------------------------- 1 | # Dohnut with Pi-hole 2 | 3 | [Pi-hole](https://pi-hole.net) is an effective way to block ads across all devices on a network. It provides many powerful options and is easy to deploy and manage. 4 | 5 | Dohnut works with Pi-hole as a local upstream DNS server. Dohnut encrypts outbound DNS queries and can load-balance between multiple DoH providers for performance and privacy benefits. Additional countermeasures supported by Dohnut can be enabled to deter tracking even by DoH providers. 6 | 7 | Tip: See the [Dohnut with Docker Compose](../docker-compose-pihole) guide for an easy way to run Pi-hole and Dohnut together. 8 | 9 | ## Deploy Dohnut 10 | 11 | Dohnut can run on the same device as Pi-hole. A popular approach is to [set up Raspbian Linux on a Raspberry Pi](../raspbian). 12 | 13 | [Run Dohnut in Docker](../docker) or [run Dohnut with systemd](../systemd). 14 | 15 | ## Configure Dohnut 16 | 17 | Pi-hole exposes a DNS server on port `53/udp`. Dohnut can avoid conflict by running on a different port, for example `53000`. 18 | 19 | The only DNS "client" talking directly to Dohnut will be Pi-hole. If both are deployed on the same machine, Dohnut can be restricted to allow only on local connections by listening on a loopback interface `127.0.0.1`. 20 | 21 | --listen 127.0.0.1:53000 22 | 23 | Specify any other [command line interface options](../cli) as needed. These options can be passed to the `dohnut` command directly, via a JSON file (e.g. `--options dohnut.json`), or as arguments to the Docker image using `docker run`. 24 | 25 | For example: 26 | 27 | $ dohnut \ 28 | --listen 127.0.0.1:53000 \ 29 | --doh cleanbrowsing cloudflare commonshost quad9 \ 30 | --countermeasures spoof-queries spoof-useragent 31 | 32 | ## Deploy Pi-hole 33 | 34 | See the [Pi-hole documentation](https://docs.pi-hole.net) for installation instructions. 35 | 36 | ## Configure Pi-hole 37 | 38 | Access the Pi-hole dashboard and log in as administrator. 39 | 40 | https://pi.hole/admin (or the Pi-hole's IP address) 41 | 42 | Go to: **Settings** > **DNS** > **Upstream DNS Servers** > **Custom 1 (IPv4)** 43 | 44 | Enter the Dohnut IP address and port using the hash syntax (`address#port`). Enable its checkbox. 45 | 46 | 127.0.0.1#53000 47 | 48 | Disable any other Upstream DNS servers to ensure all DNS queries make use of Dohnut. 49 | 50 | All your DNS queries through Pi-hole are now encrypted and load balanced for enhanced security, privacy, and performance. 51 | -------------------------------------------------------------------------------- /docs/raspbian/README.md: -------------------------------------------------------------------------------- 1 | # Dohnut with Raspbian 2 | 3 | This guide explains how to set up [Raspbian Linux](http://www.raspbian.org) on a [Raspberry Pi](https://www.raspberrypi.org) computer. You can then run [Dohnut with systemd](../systemd). Raspbian is also a great platform to run [Dohnut with Pi-hole](../pihole). 4 | 5 | ## Requirements 6 | 7 | - Raspberry Pi 8 | - SD card (2+ GB) 9 | - LAN cable or keyboard & monitor 10 | 11 | ## Operating System 12 | 13 | Download the latest **Raspbian Lite** image from the Raspberry Pi website. 14 | 15 | https://www.raspberrypi.org/downloads/raspbian/ 16 | 17 | Install **balenaEtcher** and use it to flash the Raspbian Lite image to an SD memory card. 18 | 19 | https://www.balena.io/etcher/ 20 | 21 | Re-insert the SD card into your computer. Create an empty file or directory called `ssh` at the top level of the SD card. This enables network access via SSH, a command line interface, for "headless" installation. Skip this step if you prefer attaching a keyboard and monitor for graphical desktop access. 22 | 23 | ## Hardware 24 | 25 | Insert the flashed SD memory card into the Raspberry Pi. 26 | 27 | Connect to your network router by plugging an ethernet cable into the network port of your Raspberry Pi. Or use wi-fi if your Raspberry Pi supports it. 28 | 29 | Connect power cable to USB port to boot the Raspberry Pi. Wait a minute or two for it to fully boot. 30 | 31 | ## Network 32 | 33 | The Raspberry Pi should have a static IP address. This can be achieved in several ways. Many routers offer a feature called DHCP address reservation which associates a hardware MAC address to an IP address. If available, use this technique to always assign the same IP address to your Raspberry Pi. 34 | 35 | Find the hostname or IP address of the router your Raspberry Pi is connected to. If your machine is on the same network, check your own settings. 36 | 37 | $ route get default | grep gateway 38 | 39 | Open the address in a web browser to access the administration dashboard of your router. 40 | 41 | Follow the router manual to assign a DHCP reservation to the Raspberry Pi. 42 | 43 | In this tutorial I have a assigned the IP address `192.168.1.225` to my Raspberry Pi via DHCP reservation on my router. 44 | 45 | ## Connecting 46 | 47 | Open a terminal (MacOS/Linux) or PuTTY (Windows) and connect to the IP address of your Raspberry Pi with the `pi` username. 48 | 49 | $ ssh pi@192.168.1.225 50 | 51 | The first connection attempt will ask to accept the key fingerprint. Enter `yes` and press Return. 52 | 53 | The authenticity of host '192.168.1.225 (192.168.1.225)' can't be established. 54 | ECDSA key fingerprint is SHA256:c8P+ILFKcXeyUtp5EFmzc7taNkVpu/w7ksktz1GH5gQ. 55 | Are you sure you want to continue connecting (yes/no)? yes 56 | Warning: Permanently added '192.168.1.225' (ECDSA) to the list of known hosts. 57 | 58 | When prompted to log in, enter the default password: `raspberry` 59 | 60 | pi@192.168.1.225's password: 61 | 62 | You are now logged in. 63 | 64 | ## Raspbian SSH Security 65 | 66 | Immediately secure the SSH account by changing its password. 67 | 68 | $ passwd 69 | 70 | When asked, enter the current password (default: `raspberry`). Next, enter a new password. Repeat the new password to confirm. Write the new password down somewhere or store it in a password manager. 71 | 72 | A password offers basic security and somewhat inconvenient as it must be remembered. A more secure option is to use an SSH key. That goes beyond the scope of this guide. 73 | 74 | ## Installing Dohnut 75 | 76 | The recommended way to run Dohnut on Raspbian is using the systemd service manager. 77 | 78 | See the [Dohnut with systemd](../systemd) guide for instructions. 79 | -------------------------------------------------------------------------------- /docs/systemd/README.md: -------------------------------------------------------------------------------- 1 | # Dohnut with systemd (Linux) 2 | 3 | This guide uses some commands specific to Debian-based Linux distros. This should work with popular platforms like Raspbian and Ubuntu. To use Dohnut with systemd on other flavours of Linux will require minor changes to the installation procedure. 4 | 5 | See the [Raspbian guide](../raspbian) to get started from scratch with Dohnut on a Raspberry Pi. 6 | 7 | See the [Pi-hole guide](../pihole) to combine Dohnut privacy and performance with [Pi-hole](https://pi-hole.net) DNS-based ad blocking and monitoring. 8 | 9 | ## Creating a Dohnut User 10 | 11 | Services should run under their own restricted user account. This prevents them from affecting unrelated files and processes in the case of a bug or compromise. 12 | 13 | $ sudo useradd --system --create-home --shell /bin/false dohnut 14 | 15 | ## Installing Node.js 16 | 17 | Dohnut requires a more recent version of [Node.js](https://nodejs.org) than offered by the official Raspbian package repository. To avoid potential compatibility issues with other software, we can install the latest version of Node.js just for Dohnut. Using a version manager for Node.js, like `n`, offers easy installation and future upgrades. 18 | 19 | Ensure these system dependencies are installed to allow building native NPM packages from source if necessary. 20 | 21 | $ sudo apt-get update 22 | $ sudo apt-get -y -qq install git curl libsystemd-dev build-essential libssl-dev net-tools 23 | 24 | Install `n` and the latest version of Node.js. 25 | 26 | $ curl -L https://git.io/n-install | sudo -u dohnut bash -s -- -y latest 27 | 28 | ## Installing Dohnut 29 | 30 | Download the latest version of Dohnut from [NPM](https://www.npmjs.com/package/dohnut) and install it inside the restricted `dohnut` user's home directory. Nothing else on the system is affected. 31 | 32 | $ sudo -u dohnut -- env PATH="/home/dohnut/n/bin:$PATH" npm install --global dohnut@latest 33 | 34 | ## Dohnut Configuration 35 | 36 | Create `options.json`: 37 | 38 | $ sudo mkdir -p /etc/dohnut 39 | $ sudo chown -R dohnut:dohnut /etc/dohnut 40 | $ sudo -u dohnut touch /etc/dohnut/options.json 41 | $ sudo -u dohnut nano /etc/dohnut/options.json 42 | 43 | Copy, paste, save, exit: 44 | 45 | ```json 46 | { 47 | "doh": ["commonshost"], 48 | "bootstrap": ["1.1.1.1", "8.8.8.8", "9.9.9.9"], 49 | "countermeasures": ["spoof-queries", "spoof-useragent"], 50 | "datagram-protocol": "udp4" 51 | } 52 | ``` 53 | 54 | ## Setting up systemd 55 | 56 | The [systemd](https://freedesktop.org/wiki/Software/systemd/) service manager provides access to the privileged DNS port (`53`) while securely running Dohnut with restricted permissions. 57 | 58 | Create `dohnut.service`: 59 | 60 | $ sudo nano /etc/systemd/system/dohnut.service 61 | 62 | Copy, paste, save, exit: 63 | 64 | ```ini 65 | [Unit] 66 | Description=Dohnut DNS over HTTPS proxy 67 | RefuseManualStart=true 68 | 69 | [Service] 70 | Type=notify 71 | User=dohnut 72 | Environment="NODE_ENV=production" 73 | Environment="PATH=/home/dohnut/n/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 74 | ExecStart=/home/dohnut/n/bin/npx dohnut --config /etc/dohnut/options.json 75 | Restart=always 76 | KillMode=process 77 | WatchdogSec=10 78 | SyslogIdentifier=dohnut 79 | TimeoutStartSec=infinity 80 | WorkingDirectory=~ 81 | CacheDirectory=dohnut 82 | ``` 83 | 84 | Create `dohnut.socket`: 85 | 86 | $ sudo nano /etc/systemd/system/dohnut.socket 87 | 88 | Copy, paste, save, exit: 89 | 90 | ```ini 91 | [Socket] 92 | ListenDatagram=127.0.0.1:53000 93 | ReusePort=true 94 | FileDescriptorName=dohnut 95 | 96 | [Install] 97 | WantedBy=sockets.target 98 | ``` 99 | 100 | Enable Dohnut to start listening immediately and also on boot. 101 | 102 | $ sudo systemctl --now enable dohnut.socket 103 | 104 | ## Status Check 105 | 106 | Check whether systemd is listening. 107 | 108 | $ systemctl status dohnut.socket 109 | 110 | Try a DNS lookup. This causes systemd to start Dohnut. It may take a few seconds for the first reply. 111 | 112 | $ dig @127.0.0.1 -p 53000 example.com 113 | 114 | Verify that Dohnut is running. 115 | 116 | $ systemctl status dohnut.service 117 | 118 | Check the system logs if anything went wrong. 119 | 120 | $ journalctl -xe 121 | 122 | Follow the Dohnut logs to keep an eye on things. 123 | 124 | $ journalctl -f -n 100 -u dohnut 125 | 126 | But wait, there's more... 127 | 128 | ## IPv6 129 | 130 | DNS queries can be handled over IPv6 by making small edits to the configuration above. 131 | 132 | Change `/etc/dohnut/options.json` to: 133 | 134 | "datagram-protocol": "udp6" 135 | 136 | Change `/etc/systemd/system/dohnut.socket` to: 137 | 138 | ListenDatagram=[::1]:53000 139 | 140 | Then apply the configuration and test it using `dig` over IPv6: 141 | 142 | $ sudo systemctl daemon-reload 143 | $ sudo systemctl stop dohnut.service 144 | $ sudo systemctl restart dohnut.socket 145 | $ dig @::1 -p 53000 example.com 146 | 147 | ## Updates 148 | 149 | Regularly update Node.js and Dohnut to the latest version for better performance, features, and security. 150 | 151 | $ sudo -u dohnut -- env N_PREFIX="/home/dohnut/n" /home/dohnut/n/bin/n latest 152 | $ sudo -u dohnut -- env PATH="/home/dohnut/n/bin:$PATH" npm install --global dohnut@latest 153 | 154 | ## Uninstall 155 | 156 | Removing Dohnut is clean and easy. 157 | 158 | $ sudo systemctl --now disable dohnut.socket 159 | $ sudo systemctl stop dohnut 160 | $ sudo rm -rf /etc/systemd/system/dohnut.* /etc/dohnut 161 | $ sudo systemctl daemon-reload 162 | $ sudo deluser --remove-home dohnut 163 | -------------------------------------------------------------------------------- /dohnut-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonshost/dohnut/a27afb50f12f63caf3a9ddfb81ae8b6d89badab0/dohnut-overview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dohnut", 3 | "version": "4.11.0", 4 | "description": "DNS to DNS-over-HTTPS (DoH) proxy server", 5 | "license": "ISC", 6 | "repository": "github:commonshost/dohnut", 7 | "author": "Kenny Shen ", 8 | "contributors": [ 9 | "Sebastiaan Deckers " 10 | ], 11 | "main": "source/master.js", 12 | "bin": { 13 | "dohnut": "source/bin.js" 14 | }, 15 | "scripts": { 16 | "start": "node source/bin.js", 17 | "test": "npm run lint && npm run proof", 18 | "lint": "standard --verbose | snazzy", 19 | "proof": "tape 'test/*.js'", 20 | "lcov": "nyc --reporter=lcov --reporter=text-summary tape 'test/*.js'" 21 | }, 22 | "keywords": [ 23 | "doh", 24 | "dns", 25 | "proxy" 26 | ], 27 | "devDependencies": { 28 | "blue-tape": "^1.0.0", 29 | "nyc": "^15.1.0", 30 | "snazzy": "^8.0.0", 31 | "standard": "^14.0.0", 32 | "tape": "^5.0.0" 33 | }, 34 | "dependencies": { 35 | "@commonshost/resolvers": "^1.4.0", 36 | "base64url": "^3.0.1", 37 | "chalk": "^2.4.2", 38 | "dns-packet": "^5.2.1", 39 | "pino": "^5.13.2", 40 | "please-upgrade-node": "^3.2.0", 41 | "socket-activation": "^3.2.0", 42 | "uri-templates": "^0.2.0", 43 | "user-agents": "^1.0.356", 44 | "yargs": "^14.0.0", 45 | "yauzl": "^2.10.0" 46 | }, 47 | "optionalDependencies": { 48 | "sd-notify": "^2.3.0" 49 | }, 50 | "engines": { 51 | "node": ">=11.7.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /source/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const pkg = require('../package.json') 4 | require('please-upgrade-node')(pkg) 5 | require('./cli.js') 6 | -------------------------------------------------------------------------------- /source/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { Dohnut } = require('./master') 4 | const { aliased } = require('@commonshost/resolvers') 5 | const yargs = require('yargs') 6 | const chalk = require('chalk') 7 | const { platform } = require('os') 8 | 9 | function splitStrings (array) { 10 | const values = [] 11 | for (const value of array) { 12 | if (typeof value === 'string') { 13 | values.push(...value.split(/\s+/)) 14 | } else { 15 | values.push(value) 16 | } 17 | } 18 | return values 19 | } 20 | 21 | function parseOptions ({ 22 | cacheDirectory, 23 | doh = [], 24 | listen = [], 25 | loadBalance, 26 | countermeasures, 27 | bootstrap, 28 | datagramProtocol 29 | }) { 30 | const configuration = { 31 | cacheDirectory, 32 | dns: [], 33 | doh: [], 34 | loadBalance, 35 | countermeasures, 36 | bootstrap, 37 | datagramProtocol 38 | } 39 | 40 | for (const service of doh) { 41 | let url 42 | if (aliased.doh.has(service)) { 43 | url = aliased.doh.get(service).doh 44 | } else { 45 | const hasScheme = /^https?:\/\// 46 | const upstream = hasScheme.test(service) ? service : `https://${service}` 47 | url = new URL(upstream).toString() 48 | } 49 | configuration.doh.push({ uri: url }) 50 | } 51 | 52 | for (const listener of listen) { 53 | const matchPort = /^:?(\d{1,5})$/ 54 | const matchIpv4 = /^([\d.]{1,15})(?::(\d{1,5}))?$/ 55 | const matchIpv6 = /^\[?([a-fA-F\d:]{2,40})\]?(?::(\d{1,5}))?$/ 56 | let type, address, port 57 | if (matchPort.test(listener)) { 58 | ({ $1: port } = RegExp) 59 | type = datagramProtocol 60 | address = datagramProtocol === 'udp4' ? '127.0.0.1' : '::1' 61 | } else if (matchIpv4.test(listener)) { 62 | ({ $1: address, $2: port } = RegExp) 63 | type = 'udp4' 64 | } else if (matchIpv6.test(listener)) { 65 | ({ $1: address, $2: port } = RegExp) 66 | type = 'udp6' 67 | } else { 68 | throw new Error(`Not recognized as IPv4/IPv6 address: ${listener}`) 69 | } 70 | configuration.dns.push({ address, type, port: Number(port) || 53 }) 71 | } 72 | 73 | switch (platform()) { 74 | case 'darwin': 75 | case 'linux': { 76 | const socketActivation = require('socket-activation') 77 | try { 78 | for (const fd of socketActivation.collect('dohnut')) { 79 | configuration.dns.push({ fd, type: datagramProtocol }) 80 | } 81 | } catch (error) { 82 | switch (error.code) { 83 | case 'ESRCH': 84 | break 85 | case 'ENOENT': 86 | console.warn(error.message) 87 | break 88 | default: 89 | throw error 90 | } 91 | } 92 | break 93 | } 94 | } 95 | 96 | if (!configuration.cacheDirectory) { 97 | configuration.cacheDirectory = process.cwd() 98 | } 99 | 100 | if (configuration.doh.length === 0) { 101 | throw new Error('No upstream DoH services specified.') 102 | } 103 | 104 | if (configuration.dns.length === 0) { 105 | throw new Error('No local DNS listeners specified.') 106 | } 107 | 108 | return configuration 109 | } 110 | 111 | async function main () { 112 | const { argv } = yargs 113 | .env('DOHNUT') 114 | .option('doh', { 115 | coerce: splitStrings, 116 | type: 'array', 117 | alias: ['upstream', 'proxy'], 118 | describe: 'URI Templates or shortnames of upstream DNS over HTTPS resolvers', 119 | default: [] 120 | }) 121 | .option('listen', { 122 | coerce: splitStrings, 123 | type: 'array', 124 | alias: ['local', 'l'], 125 | describe: 'IPs and ports for the local DNS server', 126 | default: [] 127 | }) 128 | .option('test', { 129 | type: 'boolean', 130 | alias: ['validate', 'configtest'], 131 | describe: 'Validate the arguments without starting the server', 132 | default: false 133 | }) 134 | .option('load-balance', { 135 | alias: ['lb'], 136 | type: 'string', 137 | describe: 'Strategy when using multiple DoH resolvers', 138 | choices: ['performance', 'privacy'], 139 | default: 'performance' 140 | }) 141 | .option('countermeasures', { 142 | coerce: splitStrings, 143 | type: 'array', 144 | describe: 'Special tactics to protect your privacy', 145 | choices: ['spoof-queries', 'spoof-useragent'], 146 | default: [] 147 | }) 148 | .option('bootstrap', { 149 | coerce: splitStrings, 150 | type: 'array', 151 | describe: 'IP addresses of DNS servers used to resolve the DoH URI hostname', 152 | default: [] 153 | }) 154 | .option('datagram-protocol', { 155 | type: 'string', 156 | describe: 'Use IPv4 or IPv6 with unspecified listen addresses and file descriptors', 157 | choices: ['udp4', 'udp6'], 158 | default: 'udp6' 159 | }) 160 | .option('cacheDirectory', { 161 | type: 'string', 162 | describe: 'Directory path to store cached data. Defaults to current working directory.', 163 | default: '' 164 | }) 165 | .example('') 166 | .example('--listen 127.0.0.1 ::1 --doh commonshost') 167 | .example('Only allow localhost connections. Proxy to the Commons Host DoH service.') 168 | .example('') 169 | .example('--doh https://localhost/my-own-resolver') 170 | .example('Use a custom resolver.') 171 | .example('') 172 | .example('--doh commonshost cloudflare quad9 cleanbrowsing') 173 | .example('Multiple DoH service can be used. Shortnames for popular services are supported.') 174 | .example('') 175 | .example('--listen :: 0.0.0.0') 176 | .example('Listen on all network interfaces using both IPv6 and IPv4.') 177 | .example('') 178 | .example('--listen 8053') 179 | .example('Listen on a non-privileged port (>=1024).') 180 | .example('') 181 | .example('--port 53 --datagram-protocol udp4') 182 | .example('Listen on 127.0.0.1:53 using UDP over IPv4.') 183 | .example('') 184 | .example('--port 53 --datagram-protocol udp6') 185 | .example('Listen on [::1]:53 using UDP over IPv6.') 186 | .example('') 187 | .example('--test --doh https://example.com --listen 192.168.12.34') 188 | .example('Check the syntax of the URI and IP address arguments. No connections are attempted.') 189 | .example('') 190 | .example('--load-balance privacy --doh quad9 cloudflare commonshost') 191 | .example('Send queries to one of multiple DoH services at random for increased privacy.') 192 | .example('') 193 | .example('--load-balance performance --doh quad9 cloudflare commonshost') 194 | .example('Send queries to the fastest DoH service by measuring ping round-trip-times.') 195 | .example('') 196 | .example('--countermeasures spoof-queries --cache-directory /etc/dohnut') 197 | .example('Randomly send fake DNS queries as disinformation to deter tracking by resolvers.') 198 | .example('The domains are saved in the cache directory for reuse on restart.') 199 | .example('') 200 | .example('--countermeasures spoof-useragent') 201 | .example('Mimic popular web browsers by including a random User-Agent header with each request. Default is no User-Agent header.') 202 | .example('') 203 | .example('--bootstrap 192.168.1.1 1.1.1.1 8.8.8.8 9.9.9.9') 204 | .example('Bypass the operating system DNS settings when resolving a DoH service hostname.') 205 | .example('') 206 | .example('Shortnames mapped to a DoH URI:') 207 | .example(Array.from(aliased.doh.keys()).sort().join(', ')) 208 | .config() 209 | .version() 210 | .help() 211 | .wrap(null) 212 | 213 | const configuration = parseOptions(argv) 214 | 215 | if (argv.test) { 216 | console.log('Configuration is valid') 217 | console.log(configuration) 218 | process.exit(0) 219 | } 220 | 221 | const dohnut = new Dohnut(configuration) 222 | await dohnut.start() 223 | 224 | for (const signal of ['SIGINT', 'SIGTERM']) { 225 | process.on(signal, async () => { 226 | console.log(`${signal} received`) 227 | await dohnut.stop() 228 | if (notify) { 229 | notify.stopWatchdogMode() 230 | } 231 | }) 232 | } 233 | 234 | let notify 235 | try { 236 | notify = require('sd-notify') 237 | } catch (error) { 238 | } 239 | if (notify) { 240 | const watchdogInterval = notify.watchdogInterval() 241 | if (watchdogInterval > 0) { 242 | const interval = Math.max(500, Math.floor(watchdogInterval / 2)) 243 | notify.startWatchdogMode(interval) 244 | console.log('Started systemd heartbeat') 245 | } 246 | notify.ready() 247 | console.log('Notified systemd ready') 248 | } 249 | } 250 | 251 | main() 252 | .catch((error) => { 253 | error.message = chalk.red(error.message) 254 | console.trace(error) 255 | process.exit(1) 256 | }) 257 | -------------------------------------------------------------------------------- /source/getPopularDomains.js: -------------------------------------------------------------------------------- 1 | const { get } = require('https') 2 | const { createBrotliCompress, createBrotliDecompress, constants } = require('zlib') 3 | const { brotliCompressSync, brotliDecompressSync } = require('zlib') 4 | const { createReadStream, createWriteStream, statSync } = require('fs') 5 | const { readFileSync, writeFileSync } = require('fs') 6 | const { tmpdir } = require('os') 7 | const { join } = require('path') 8 | const { createInterface } = require('readline') 9 | const yauzl = require('yauzl') 10 | 11 | const LIST_URL = 'https://s3-us-west-1.amazonaws.com/umbrella-static/top-1m.csv.zip' 12 | 13 | function download (url, archivepath) { 14 | return new Promise((resolve, reject) => { 15 | const request = get(url, (response) => { 16 | if (response.headers['content-type'] !== 'application/zip') { 17 | reject(new Error('Unexpected Content-Type header')) 18 | } 19 | const file = createWriteStream(archivepath) 20 | response.on('error', reject) 21 | file.on('error', reject) 22 | response.pipe(file) 23 | file.on('close', resolve) 24 | }) 25 | request.on('error', reject) 26 | }) 27 | } 28 | 29 | function unzip (archivepath, entrypath, csvpath) { 30 | return new Promise((resolve, reject) => { 31 | yauzl.open(archivepath, { lazyEntries: true }, (error, zipfile) => { 32 | if (error) return reject(error) 33 | zipfile.readEntry() 34 | zipfile.on('entry', (entry) => { 35 | if (entry.fileName !== entrypath) { 36 | return reject(new Error('Unexpected file in archive')) 37 | } 38 | zipfile.openReadStream(entry, (error, readStream) => { 39 | if (error) return reject(error) 40 | readStream.on('error', reject) 41 | const file = createWriteStream(csvpath) 42 | readStream.pipe(file) 43 | readStream.on('end', resolve) 44 | }) 45 | }) 46 | zipfile.on('end', () => reject(new Error('Empty archive'))) 47 | }) 48 | }) 49 | } 50 | 51 | function cleanupSync (csvpath, listpath) { 52 | return new Promise((resolve, reject) => { 53 | const lines = readFileSync(csvpath, { encoding: 'utf8' }).split('\r\n') 54 | const concat = [] 55 | for (const line of lines) { 56 | const [, domain] = line.split(',') 57 | if (domain !== undefined && domain.length > 0) { 58 | concat.push(domain) 59 | } 60 | } 61 | const compressed = brotliCompressSync(concat.join('\n'), { 62 | params: { 63 | [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT, 64 | [constants.BROTLI_PARAM_QUALITY]: 1, 65 | [constants.BROTLI_PARAM_SIZE_HINT]: statSync(csvpath).size 66 | } 67 | }) 68 | writeFileSync(listpath, compressed) 69 | resolve() 70 | }) 71 | } 72 | 73 | function cleanup (csvpath, listpath) { 74 | return new Promise((resolve, reject) => { 75 | const csv = createReadStream(csvpath) 76 | csv.on('error', reject) 77 | const compressor = createBrotliCompress({ 78 | params: { 79 | [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT, 80 | [constants.BROTLI_PARAM_QUALITY]: 1, 81 | [constants.BROTLI_PARAM_SIZE_HINT]: statSync(csvpath).size 82 | } 83 | }) 84 | compressor.on('error', reject) 85 | const output = createWriteStream(listpath) 86 | output.on('error', reject) 87 | compressor.pipe(output) 88 | const readlines = createInterface({ 89 | input: csv, 90 | crlfDelay: Infinity 91 | }) 92 | const concat = [] 93 | readlines.on('line', (line) => { 94 | const [, domain] = line.split(',') 95 | if (domain !== undefined && domain.length > 0) { 96 | concat.push(domain) 97 | } 98 | }) 99 | readlines.on('close', () => { 100 | compressor.end(concat.join('\n')) 101 | }) 102 | output.on('close', resolve) 103 | }) 104 | } 105 | 106 | function loadSync (listpath) { 107 | return new Promise((resolve, reject) => { 108 | const raw = readFileSync(listpath) 109 | const text = brotliDecompressSync(raw) 110 | const domains = text.toString().split('\n') 111 | resolve(domains) 112 | }) 113 | } 114 | 115 | function load (listpath) { 116 | return new Promise((resolve, reject) => { 117 | const domains = [] 118 | const list = createReadStream(listpath) 119 | list.on('error', reject) 120 | const decompressor = createBrotliDecompress() 121 | decompressor.on('error', reject) 122 | list.pipe(decompressor) 123 | const readlines = createInterface({ 124 | input: decompressor, 125 | crlfDelay: Infinity 126 | }) 127 | readlines.on('line', (line) => { 128 | domains.push(line) 129 | }) 130 | readlines.on('close', () => { 131 | resolve(domains) 132 | }) 133 | }) 134 | } 135 | 136 | async function getPopularDomains ({ 137 | cacheDirectory = process.cwd(), 138 | sync = true, 139 | verbose = true 140 | }) { 141 | const url = LIST_URL 142 | const scratch = tmpdir() 143 | const archivepath = join(scratch, 'top-1m.csv.zip') 144 | const entrypath = 'top-1m.csv' 145 | const csvpath = join(scratch, 'top-1m.csv') 146 | const listpath = join(cacheDirectory, 'top-1m.txt.br') 147 | const log = verbose ? console.log : Function 148 | let domains 149 | try { 150 | domains = await (sync ? loadSync : load)(listpath) 151 | } catch (error) { 152 | if (error.code === 'ENOENT') { 153 | log('Downloading list of popular domains...') 154 | await download(url, archivepath) 155 | log('Extracting archive...') 156 | await unzip(archivepath, entrypath, csvpath) 157 | log('Minifying domains...') 158 | await (sync ? cleanupSync : cleanup)(csvpath, listpath) 159 | const sizeMB = (statSync(listpath).size / 1e6) 160 | .toLocaleString(undefined, { maximumFractionDigits: 1 }) 161 | log(`Domains cached at: ${listpath} (${sizeMB} MB)`) 162 | domains = await (sync ? loadSync : load)(listpath) 163 | } else { 164 | throw error 165 | } 166 | } 167 | return domains 168 | } 169 | 170 | module.exports.getPopularDomains = getPopularDomains 171 | -------------------------------------------------------------------------------- /source/master.js: -------------------------------------------------------------------------------- 1 | const Pino = require('pino') 2 | const { createSocket } = require('dgram') 3 | const { join } = require('path') 4 | const EventEmitter = require('events') 5 | const { once } = require('events') 6 | const { Worker } = require('worker_threads') 7 | const { getPopularDomains } = require('./getPopularDomains') 8 | 9 | const PING_MIN_INTERVAL = 600000 10 | 11 | function startUdpSocket ({ type, address, port, fd }) { 12 | return new Promise((resolve, reject) => { 13 | const socket = createSocket(type) 14 | function onSuccess () { 15 | socket.off('error', onFailure) 16 | resolve(socket) 17 | } 18 | function onFailure (error) { 19 | socket.off('listening', onSuccess) 20 | reject(error) 21 | } 22 | socket.once('listening', onSuccess) 23 | socket.once('error', onFailure) 24 | socket.bind({ address, port, fd }) 25 | }) 26 | } 27 | 28 | function stopUdpSocket (socket) { 29 | return new Promise((resolve, reject) => { 30 | function onSuccess () { 31 | socket.off('error', onFailure) 32 | resolve(socket) 33 | } 34 | function onFailure (error) { 35 | socket.off('close', onSuccess) 36 | reject(error) 37 | } 38 | socket.once('close', onSuccess) 39 | socket.once('error', onFailure) 40 | socket.close() 41 | }) 42 | } 43 | 44 | function sum (numbers) { 45 | let count = 0 46 | for (const number of numbers) { 47 | count += number 48 | } 49 | return count 50 | } 51 | 52 | function randomWeighted (weights, total) { 53 | const random = Math.random() * total 54 | let step = total 55 | let index = weights.length 56 | for (const weight of weights) { 57 | step -= weight 58 | if (random >= step) { 59 | return weights.length - index 60 | } 61 | index-- 62 | } 63 | } 64 | 65 | class Dohnut { 66 | constructor (configuration) { 67 | this.configuration = configuration 68 | this.log = new Pino() 69 | this.dns = new Set() 70 | this.doh = [] 71 | this.queries = new Map() 72 | this.queryIds = [] 73 | this.counter = 0 74 | this.timer = null 75 | this.lastPingTime = 0 76 | this.fastestConnection = undefined 77 | this.getConnection = this.configuration.loadBalance === 'privacy' 78 | ? this.getRandomConnection : this.getFastestConnection 79 | this.popularDomains = undefined 80 | } 81 | 82 | getRandomConnection () { 83 | const index = Math.floor(Math.random() * this.doh.length) 84 | const connection = this.doh[index] 85 | return connection 86 | } 87 | 88 | getFastestConnection () { 89 | return this.fastestConnection || this.getRandomConnection() 90 | } 91 | 92 | refreshPing () { 93 | const pinged = [] 94 | for (const connection of this.doh) { 95 | if (connection.pinged === false) { 96 | connection.ping() 97 | } else if (connection.rtt !== undefined) { 98 | pinged.push(connection) 99 | } 100 | } 101 | 102 | if (pinged.length > 0) { 103 | const rtts = pinged.map(({ rtt }) => rtt) 104 | const slowest = Math.max(...rtts) 105 | const weights = rtts.map((rtt) => 1 / (rtt / slowest)) 106 | const total = sum(weights) 107 | const index = randomWeighted(weights, total) 108 | const target = pinged[index] 109 | target.ping() 110 | } 111 | } 112 | 113 | async start () { 114 | const options = { 115 | bootstrap: this.configuration.bootstrap, 116 | spoofUseragent: this.configuration.countermeasures 117 | .includes('spoof-useragent') 118 | } 119 | for (const { uri } of this.configuration.doh) { 120 | const connection = new Connection(uri, options) 121 | this.doh.push(connection) 122 | connection.on('response', ({ id, message }) => { 123 | if (this.queries.has(id)) { 124 | const query = this.queries.get(id) 125 | this.queries.delete(id) 126 | query.socket.send(message, query.port, query.address) 127 | } 128 | }) 129 | connection.on('ping', () => { 130 | let fastest = this.fastestConnection 131 | for (const connection of this.doh) { 132 | if (connection.rtt !== undefined) { 133 | if (fastest === undefined || connection.rtt < fastest.rtt) { 134 | fastest = connection 135 | } 136 | } 137 | } 138 | if (this.fastestConnection !== fastest) { 139 | this.fastestConnection = fastest 140 | if (fastest) { 141 | const { uri, rtt } = fastest 142 | console.log(`Fastest connection: ${uri} (RTT: ${rtt} ms)`) 143 | } 144 | } 145 | }) 146 | } 147 | 148 | this.timer = setInterval(() => { 149 | const now = Date.now() 150 | const ttl = 5000 151 | let index = 0 152 | for (const id of this.queryIds) { 153 | if (this.queries.has(id)) { 154 | const query = this.queries.get(id) 155 | const elapsed = now - query.begin 156 | if (elapsed > ttl) { 157 | this.queries.delete(id) 158 | index++ 159 | } else { 160 | break 161 | } 162 | } else { 163 | index++ 164 | } 165 | } 166 | if (index > 0) { 167 | this.queryIds = this.queryIds.slice(index) 168 | } 169 | }, 1000) 170 | 171 | if (this.configuration.countermeasures.includes('spoof-queries')) { 172 | this.popularDomains = await getPopularDomains({ 173 | cacheDirectory: this.configuration.cacheDirectory 174 | }) 175 | const count = this.popularDomains.length.toLocaleString() 176 | console.log(`Loaded ${count} popular domains`) 177 | } 178 | 179 | for (const resolver of this.configuration.dns) { 180 | const socket = await startUdpSocket(resolver) 181 | const { address, port } = socket.address() 182 | const location = resolver.fd !== undefined ? `unix:${resolver.fd}` 183 | : resolver.type === 'udp4' ? `${address}:${port}` 184 | : `[${address}]:${port}` 185 | console.log(`Started listening on ${location} (${resolver.type})`) 186 | socket.on('message', ({ buffer }, remote) => { 187 | const now = Date.now() 188 | const query = { 189 | id: ++this.counter, 190 | family: remote.family, 191 | address: remote.address, 192 | port: remote.port, 193 | message: buffer, 194 | begin: now, 195 | socket 196 | } 197 | this.queries.set(query.id, query) 198 | this.queryIds.push(query.id) 199 | const connection = this.getConnection() 200 | const message = { query: { id: query.id, message: query.message } } 201 | if (this.popularDomains !== undefined) { 202 | const curviness = 10 203 | const random = Math.exp(-Math.random() * curviness) 204 | const maximum = this.popularDomains.length 205 | const index = Math.floor(maximum * random) 206 | message.query.spoofDomain = this.popularDomains[index] 207 | } 208 | connection.send(message) 209 | if (now > this.lastPingTime + PING_MIN_INTERVAL) { 210 | this.lastPingTime = now 211 | this.refreshPing() 212 | } 213 | }) 214 | socket.on('close', async () => { 215 | console.log(`Stopped listening on ${location} (${resolver.type})`) 216 | }) 217 | this.dns.add(socket) 218 | } 219 | } 220 | 221 | async stop () { 222 | clearInterval(this.timer) 223 | for (const socket of this.dns) { 224 | await stopUdpSocket(socket) 225 | this.dns.delete(socket) 226 | } 227 | while (this.doh.length > 0) { 228 | const connection = this.doh.pop() 229 | await connection.stop() 230 | } 231 | } 232 | } 233 | 234 | class Connection extends EventEmitter { 235 | constructor (uri, options) { 236 | super() 237 | this.uri = uri 238 | this.worker = undefined 239 | this.pending = [] 240 | this.state = 'disconnected' // connecting, connected 241 | this.pinged = false 242 | this.rtt = undefined 243 | this.options = options 244 | this.tlsSession = undefined 245 | } 246 | 247 | send (message) { 248 | switch (this.state) { 249 | case 'connected': 250 | this.worker.postMessage(message) 251 | break 252 | case 'connecting': 253 | this.pending.push(message) 254 | break 255 | case 'disconnected': 256 | this.pending.push(message) 257 | this.state = 'connecting' 258 | if (this.worker === undefined) { 259 | this.worker = new Worker(join(__dirname, 'worker.js')) 260 | this.worker.on('message', this.receive.bind(this)) 261 | this.worker.once('exit', () => { this.worker = undefined }) 262 | } 263 | this.worker.postMessage({ 264 | uri: this.uri, 265 | spoofUseragent: this.options.spoofUseragent, 266 | bootstrap: this.options.bootstrap, 267 | tlsSession: this.tlsSession 268 | }) 269 | break 270 | } 271 | } 272 | 273 | receive (value) { 274 | if ('state' in value) { 275 | const { state } = value 276 | this.state = state 277 | switch (state) { 278 | case 'connected': { 279 | console.log(`Worker ${this.worker.threadId}: connected`, 280 | `(TLS session resumed: ${value.isSessionReused})`) 281 | const { pending } = this 282 | while (pending.length > 0) { 283 | const message = pending.shift() 284 | this.send(message) 285 | } 286 | break 287 | } 288 | case 'disconnected': { 289 | console.log(`Worker ${this.worker.threadId}: disconnected`) 290 | if (this.pinged === true && this.rtt === undefined) { 291 | this.pinged = false 292 | } 293 | break 294 | } 295 | } 296 | } else if ('response' in value) { 297 | this.emit('response', value.response) 298 | if (value.response.error === 'http') { 299 | this.rtt = undefined 300 | this.emit('ping') 301 | } 302 | } else if ('ping' in value) { 303 | this.rtt = value.ping.duration 304 | this.emit('ping') 305 | } else if ('busy' in value) { 306 | this.send(value.busy.message) 307 | } else if ('tlsSession' in value) { 308 | this.tlsSession = value.tlsSession 309 | } 310 | } 311 | 312 | ping () { 313 | this.pinged = true 314 | this.rtt = undefined 315 | this.send({ ping: {} }) 316 | } 317 | 318 | async stop () { 319 | if (this.worker) { 320 | this.worker.postMessage({ exit: true }) 321 | await once(this.worker, 'exit') 322 | } 323 | } 324 | } 325 | 326 | module.exports.Dohnut = Dohnut 327 | -------------------------------------------------------------------------------- /source/worker.js: -------------------------------------------------------------------------------- 1 | const { parentPort, threadId } = require('worker_threads') 2 | const { 3 | connect, 4 | constants: { 5 | HTTP2_METHOD_POST, 6 | HTTP2_METHOD_GET, 7 | HTTP2_HEADER_ACCEPT, 8 | HTTP2_HEADER_CONTENT_LENGTH, 9 | HTTP2_HEADER_CONTENT_TYPE, 10 | HTTP2_HEADER_USER_AGENT, 11 | HTTP2_HEADER_METHOD, 12 | HTTP2_HEADER_PATH, 13 | HTTP2_HEADER_STATUS 14 | } 15 | } = require('http2') 16 | const { Resolver } = require('dns') 17 | const { isIPv6 } = require('net') 18 | const UriTemplate = require('uri-templates') 19 | const { encode } = require('base64url') 20 | const dnsPacket = require('dns-packet') 21 | const UserAgent = require('user-agents') 22 | 23 | const DNS_MESSAGE = 'application/dns-message' 24 | 25 | let session 26 | let uri 27 | let path 28 | let spoofUseragent = false 29 | 30 | const useragent = new UserAgent() 31 | let randomUseragent 32 | 33 | function getPath (uri) { 34 | const { pathname, search } = new URL(uri) 35 | return search 36 | ? pathname + '?' + search 37 | : pathname 38 | } 39 | 40 | function dnsErrorServFail (id, query) { 41 | const { questions, flags } = dnsPacket.decode(query) 42 | 43 | // http://www.faqs.org/rfcs/rfc2929.html 44 | // 1 1 1 1 1 1 45 | // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 46 | // +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ 47 | // |QR| Opcode |AA|TC|RD|RA| Z|AD|CD| RCODE | 48 | 49 | return dnsPacket.encode({ 50 | id, 51 | type: 'response', 52 | flags: 53 | (0b0111100000000000 & flags) | // opcode copied from query 54 | (0b0000000100000000 & flags) | // rd copied from query 55 | (0b0000000010000000) | // ra always true 56 | (0b0000000000000010), // rcode always ServFail 57 | questions 58 | }) 59 | } 60 | 61 | function sendQuery (query) { 62 | const headers = {} 63 | headers[HTTP2_HEADER_ACCEPT] = DNS_MESSAGE 64 | if (spoofUseragent === true) { 65 | headers[HTTP2_HEADER_USER_AGENT] = randomUseragent 66 | } 67 | let stream 68 | if (uri.varNames.includes('dns')) { 69 | headers[HTTP2_HEADER_METHOD] = HTTP2_METHOD_GET 70 | const dns = encode(query) 71 | headers[HTTP2_HEADER_PATH] = uri.fill({ dns }) 72 | stream = session.request(headers, { endStream: true }) 73 | } else { 74 | headers[HTTP2_HEADER_METHOD] = HTTP2_METHOD_POST 75 | headers[HTTP2_HEADER_CONTENT_TYPE] = DNS_MESSAGE 76 | headers[HTTP2_HEADER_CONTENT_LENGTH] = query.byteLength 77 | headers[HTTP2_HEADER_PATH] = path 78 | stream = session.request(headers) 79 | stream.end(query) 80 | } 81 | return stream 82 | } 83 | 84 | function spoofQuery (domain) { 85 | function spoof () { 86 | const query = dnsPacket.encode({ 87 | type: 'query', 88 | questions: [{ 89 | type: 'A', 90 | name: domain 91 | }] 92 | }) 93 | let stream 94 | try { 95 | stream = sendQuery(query) 96 | } catch (error) { 97 | console.error(`Worker ${threadId}: spoofed query failed - ${error.message}`) 98 | return 99 | } 100 | stream.on('error', (error) => { 101 | console.error(`Worker ${threadId}: spoofed query error - ${error.message}`) 102 | stream.close() 103 | }) 104 | stream.resume() 105 | } 106 | const random = Math.random() 107 | if (random < 0.25) { 108 | // Spoof before real query 109 | spoof() 110 | } else if (random < 0.50) { 111 | // Spoof after real query 112 | setImmediate(spoof) 113 | } else if (random < 0.75) { 114 | // Delay spoofed query up to 10s 115 | const delay = Math.random() * 10000 116 | setTimeout(spoof, delay) 117 | } else { 118 | // Do not spoof 119 | } 120 | } 121 | 122 | function lookupCustomDnsServers (servers) { 123 | const resolver = new Resolver() 124 | resolver.setServers(servers) 125 | return (hostname, { family, all = false, verbatim = false }, callback) => { 126 | function onResolve (error, records) { 127 | if (error) { 128 | callback(error) 129 | } else if (all === true) { 130 | const mapped = records.map((address) => ({ 131 | address, 132 | family: isIPv6(address) ? 6 : 4 133 | })) 134 | if (verbatim === true) { 135 | callback(null, records) 136 | } else { 137 | const sorted = mapped.sort(({ family }) => family === 6 ? -1 : 1) 138 | callback(null, sorted) 139 | } 140 | } else { 141 | let first 142 | if (verbatim === true) { 143 | first = records[0] 144 | } else { 145 | first = records.find(isIPv6) || records[0] 146 | } 147 | const family = isIPv6(first) ? 6 : 4 148 | callback(null, first, family) 149 | } 150 | } 151 | 152 | if (family === 4) { 153 | resolver.resolve4(hostname, onResolve) 154 | } else if (family === 6) { 155 | resolver.resolve6(hostname, onResolve) 156 | } else { 157 | resolver.resolve(hostname, onResolve) 158 | } 159 | } 160 | } 161 | 162 | parentPort.on('message', (value) => { 163 | if ('query' in value) { 164 | if (session.destroyed) { 165 | parentPort.postMessage({ busy: { message: value } }) 166 | return 167 | } 168 | if ('spoofDomain' in value.query) { 169 | spoofQuery(value.query.spoofDomain) 170 | } 171 | const query = Buffer.from(value.query.message) 172 | const dnsId = query.readUInt16BE(0) 173 | query.writeUInt16BE(0, 0) 174 | const stream = sendQuery(query) 175 | stream.on('error', (error) => { 176 | console.error(`Worker ${threadId}: stream error - ${error.message}`) 177 | const message = dnsErrorServFail(dnsId, query) 178 | const response = { id: value.query.id, message, error: 'http' } 179 | parentPort.postMessage({ response }) 180 | stream.close() 181 | }) 182 | stream.on('response', (headers) => { 183 | const status = headers[HTTP2_HEADER_STATUS] 184 | const contentType = headers[HTTP2_HEADER_CONTENT_TYPE] 185 | if (status !== 200 || contentType !== DNS_MESSAGE) { 186 | console.error(`Worker ${threadId}: HTTP ${status} (${contentType})`) 187 | const message = dnsErrorServFail(dnsId, query) 188 | const response = { id: value.query.id, message, error: 'http' } 189 | parentPort.postMessage({ response }) 190 | stream.close() 191 | return 192 | } 193 | const chunks = [] 194 | stream.on('data', (chunk) => chunks.push(chunk)) 195 | stream.on('end', () => { 196 | const message = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks) 197 | message.writeUInt16BE(dnsId, 0) 198 | const response = { id: value.query.id, message } 199 | parentPort.postMessage({ response }) 200 | }) 201 | }) 202 | } else if ('ping' in value) { 203 | if (session.destroyed) { 204 | parentPort.postMessage({ busy: { message: value } }) 205 | return 206 | } 207 | session.ping((error, duration, payload) => { 208 | if (error) { 209 | console.error(`Worker ${threadId}: ping failed - ${error.message}`) 210 | } else { 211 | parentPort.postMessage({ ping: { duration } }) 212 | } 213 | }) 214 | } else if ('uri' in value) { 215 | console.log(`Worker ${threadId}: connecting to ${value.uri}`) 216 | uri = new UriTemplate(value.uri) 217 | spoofUseragent = value.spoofUseragent 218 | randomUseragent = useragent.random().toString() 219 | path = getPath(value.uri) 220 | const options = { 221 | maxSessionMemory: Number.MAX_SAFE_INTEGER, 222 | peerMaxConcurrentStreams: 2 ** 32 - 1, 223 | settings: { 224 | enablePush: false, 225 | maxConcurrentStreams: 2 ** 32 - 1 226 | } 227 | } 228 | if (value.bootstrap.length > 0) { 229 | options.lookup = lookupCustomDnsServers(value.bootstrap) 230 | } 231 | if (value.tlsSession) { 232 | options.session = value.tlsSession 233 | } 234 | session = connect(value.uri, options) 235 | for (const side of ['local', 'remote']) { 236 | const name = `${side}Settings` 237 | session.on(name, (settings) => { 238 | console.log(`Worker ${threadId}: HTTP/2`, name, settings) 239 | }) 240 | } 241 | if (session.socket.getProtocol() === 'TLSv1.2') { 242 | const tlsSession = session.socket.getSession() 243 | parentPort.postMessage({ tlsSession }) 244 | } 245 | session.socket.on('session', (tlsSession) => { 246 | parentPort.postMessage({ tlsSession }) 247 | }) 248 | session.on('connect', () => { 249 | parentPort.postMessage({ 250 | state: 'connected', 251 | isSessionReused: session.socket.isSessionReused() 252 | }) 253 | }) 254 | session.on('close', () => { 255 | parentPort.postMessage({ state: 'disconnected' }) 256 | }) 257 | session.on('error', (error) => { 258 | console.error(`Worker ${threadId}: session error ${error.message}`) 259 | }) 260 | } else if ('exit' in value) { 261 | if (session && !session.destroyed) { 262 | session.close(() => { 263 | process.exit() 264 | }) 265 | } else { 266 | process.exit() 267 | } 268 | } 269 | }) 270 | -------------------------------------------------------------------------------- /test/start-query-stop.js: -------------------------------------------------------------------------------- 1 | const test = require('blue-tape') 2 | const { Dohnut } = require('..') 3 | const { promises: { Resolver } } = require('dns') 4 | 5 | const sleep = (time) => new Promise((resolve) => setTimeout(resolve, time)) 6 | 7 | test('Start/Query/Stop', async (t) => { 8 | const configuration = { 9 | dns: [ 10 | { 11 | type: 'udp4', 12 | address: '127.0.0.1', 13 | port: 0, 14 | fd: undefined 15 | } 16 | ], 17 | doh: [ 18 | { 19 | uri: 'https://cloudflare-dns.com/dns-query' 20 | // uri: 'https://commons.host' 21 | } 22 | ], 23 | loadBalance: 'performance', // 'privacy' 24 | countermeasures: [ 25 | 'spoof-useragent', 26 | 'spoof-queries' 27 | ], 28 | cacheDirectory: process.cwd(), 29 | bootstrap: [ 30 | // '1.1.1.1' 31 | // '8.8.8.8' 32 | // '9.9.9.9' 33 | ] 34 | } 35 | const dohnut = new Dohnut(configuration) 36 | await dohnut.start() 37 | 38 | await sleep(50) 39 | 40 | const resolver = new Resolver() 41 | const [listener] = dohnut.dns 42 | const { address, port } = listener.address() 43 | resolver.setServers([`${address}:${port}`]) 44 | 45 | await t.shouldReject(resolver.resolve4('sigfail.verteiltesysteme.net')) 46 | await resolver.resolve4('sigok.verteiltesysteme.net') 47 | 48 | await dohnut.stop() 49 | }) 50 | --------------------------------------------------------------------------------