DNS Guardrails with dnscrypt-proxy

Jan 23, 2024 [ #linux #dns #containers #docker #networking #tailscale ]



Over the holidays we got our two younger children HP laptops for them to do their school work on and to have a proper computer. While the schools Google Classroom login effectively adds restrictions to Chrome, I still wanted to have some guardrails on their Internet access as well as ad-blocking.

The first thing I did was replace the Windows S install that came on the laptops with Linux Mint as I’ve always enjoyed the Cinnamon desktop environment and it has a low enough learning curve that the kids could easily pick it up. After installing a few apps and games (OpenRCT2) from the Software Center and setting their own passwords, they were up and running and surfing the world-wide-web.

Finally I added Tailscale to both laptops to put them on my tailnet. This has benefits of accessing tailnet-only services, easier remote access, and leveraging the dnscrypt-proxy on OpenBSD I setup a few years ago for DNS.


My original DNS config worked well, but I wanted to add some guardrails specifically for the kids laptops,

  1. Cloudflare for Families
  2. Ad Blocking
  3. YouTube Restricted Mode via Cloaking
  4. Accessible only from the Tailscale

First I tried using the existing dnscrypt-proxy to provide a different set of DNS resolvers depending on the source IP, but this wasn’t possible. Eventually I came up with a seperate DNS infrastructure in a container for the laptops to use instead.

Container Stack

Dockerfile is used for building a container including dnscrypt-proxy,

FROM debian:trixie-slim
ENV DEBIAN_FRONTEND noninteractive

RUN apt update && \
    apt install -y dnscrypt-proxy \
      ca-certificates \
    && apt clean

ENTRYPOINT [ "/usr/sbin/dnscrypt-proxy" ]
CMD [ "-config", "/etc/dnscrypt-proxy/dnscrypt-proxy.toml" ]

docker compose is used to bring up the stack, which includes a tailscale container to provide network and access to other devices on the tailnet. Configuration files are monted read-only from the current directly and some volumes to maintain state across restarts.


version: '3.9'
    container_name: tailscale-dnscrypt
    hostname: dnscrypt-proxy
    image: ghcr.io/tailscale/tailscale
    stdin_open: true
      - TS_USERSPACE=true
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_SOCKET=/var/run/tailscale/tailscaled.sock
      - dnscryptvarlib:/var/lib
    restart: unless-stopped
    build: .
    stdin_open: true
      - ./dnscrypt-proxy.toml:/etc/dnscrypt-proxy/dnscrypt-proxy.toml:ro
      - ./blocklist.txt:/etc/dnscrypt-proxy/blocklist.txt:ro
      - ./cloaking-rules.txt:/etc/dnscrypt-proxy/cloaking-rules.txt:ro
      - ./domains-allowlist.txt:/etc/dnscrypt-proxy/domains-allowlist.txt:ro
      - keys:/etc/dnscrypt-proxy/keys
    restart: unless-stopped
    network_mode: 'service:tailscale'


The dnscrypt-proxy configuration uses the cloudflare-family source and ad-blocking using a blocklist.txt generated with generate-domains-blocklist.py. All logs go to /dev/stdout so they appear in docker compose logs.


#Use cloudflare DNS
server_names = ['cloudflare-family']

#Listen on local and LAN addresses for DNS
listen_addresses = ['']
max_clients = 250
user_name = '_dnscrypt-proxy'

#Enable ipv4 and ipv6
ipv4_servers = true
ipv6_servers = false

#Allow TCP and UDP
force_tcp = false
timeout = 2500
keepalive = 30

log_level = 2
use_syslog = true

cert_refresh_delay = 240
dnscrypt_ephemeral_keys = true
tls_disable_session_tickets = true

cache = true

cloaking_rules = '/etc/dnscrypt-proxy/cloaking-rules.txt'

#Query logging, commented out unless for troubleshooting
  file = '/dev/stdout'
  format = 'tsv'

#Sources for resolvers and relays
  urls = ['https://raw.githubusercontent.com/DNSCrypt/dnscrypt-resolvers/master/v3/public-resolvers.md', 'https://download.dnscrypt.info/resolvers-list/v3
  minisign_key = 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'
  cache_file = '/var/tmp/public-resolvers.md'
  refresh_delay = 72

#Blocking configuration
  ## Path to the file of blocking rules (absolute, or relative to the same directory as the executable file)
  blocked_names_file = '/etc/dnscrypt-proxy/blocklist.txt'
  log_file = '/dev/stdout'
  log_format = 'tsv'

#Allow configuration
  allowed_names_file = '/etc/dnscrypt-proxy/domains-allowlist.txt

Cloaking is used so all YouTube requests resolve to restrict.youtube.com.


www.youtube.com restrict.youtube.com
m.youtube.com  restrict.youtube.com
youtubei.googleapis.com restrict.youtube.com
youtube.googleapis.com restrict.youtube.com
www.youtube-nocookie.com restrict.youtube.com

Exposing via Tailscale

Since I only want DNS available to devices on my tailnet, and not publicly available, there’s a tailscale container in the docker-compose.yml that provides networking to the dnscrypt-proxy container using network_mode.

Set this up by creating an auth key for your tailnet and then putting it into a .env file that docker compose will source in and set as the TS_AUTH_KEY variable.



Enabling on Linux Mint

My tailnet uses Magic DNS which sets the nameserver for all devices on a tailnet to, but since this is a DNS server specific to a subset of systems we want to use the IP of the dnscrypt-proxy device instead.

After bringing up the stack with docker compose up, the tailscale container will authenticate to the tailnet and have an Tailscale IP (eg This IP is then added to the laptops /etc/resolv.conf,

search tailnet-3831.ts.net

Tailscale will keep trying to revert this, so to keep the settings permanent, /etc/resolv.conf is set to immutable with chattr +i /etc/resolv.conf.

To test DNS is working, looking for more “adult” content on youtube will give a message similar to “your Google workspace administrator has restricted some content”.

Verify in container logs with dig m.youtube.com @ (where the IP is your Tailscale container IP) and check the logs for messages similar to m.youtube.com A CLOAK 0ms -.