___________________________________________
title: DNS Guardrails with dnscrypt-proxy
tags: linux dns containers docker networking tailscale
date: 2024-01-23
___________________________________________

Intro

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.

  [Linux Mint]: https://www.linuxmint.com/
  [Cinnamon desktop environment]: https://projects.linuxmint.com/cinnamon/
  [OpenRCT2]: https://openrct2.org/

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.

  [Tailscale]: https://tailscale.com/
  [tailnet]: https://tailscale.com/kb/1136/tailnet
  [dnscrypt-proxy on OpenBSD]: https://www.ecliptik.com/Running-dnscrypt-proxy-on-OpenBSD/

Guardrails

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

  [Cloudflare for Families]: https://blog.cloudflare.com/introducing-1-1-1-1-for-families/
  [Ad Blocking]: https://github.com/DNSCrypt/dnscrypt-proxy/wiki/Public-blocklist
  [YouTube Restricted Mode]: https://support.google.com/a/answer/6212415

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,

  [dnscrypt-proxy]: https://github.com/DNSCrypt/dnscrypt-proxy

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

    WORKDIR /tmp
    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.

  [docker compose]: https://docs.docker.com/compose/

docker-compose.yml

    version: '3.9'
    services:
      tailscale:
        container_name: tailscale-dnscrypt
        hostname: dnscrypt-proxy
        image: ghcr.io/tailscale/tailscale
        stdin_open: true
        environment:
          - TS_AUTH_KEY=${TS_AUTH_KEY}
          - TS_USERSPACE=true
          - TS_STATE_DIR=/var/lib/tailscale
          - TS_SOCKET=/var/run/tailscale/tailscaled.sock
        volumes:
          - dnscryptvarlib:/var/lib
        restart: unless-stopped
      dnscrypt-proxy:
        build: .
        stdin_open: true
        volumes:
          - ./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'
    volumes:
      dnscryptvarlib:
      keys:

dnscrypt-proxy

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.

  [generate-domains-blocklist.py]: https://github.com/DNSCrypt/dnscrypt-proxy/wiki/Combining-Blocklists

dnscrypt-proxy.toml

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

    #Listen on local and LAN addresses for DNS
    listen_addresses = ['127.0.0.1:53']
    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

    #Logging
    log_level = 2
    use_syslog = true

    #Certs
    cert_refresh_delay = 240
    dnscrypt_ephemeral_keys = true
    tls_disable_session_tickets = true

    #Cache
    cache = true

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

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

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

    #Blocking configuration
    [blocked_names]
      ## 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]
      allowed_names_file = '/etc/dnscrypt-proxy/domains-allowlist.txt

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

  [Cloaking]: https://github.com/DNSCrypt/dnscrypt-proxy/wiki/Public-blocklist

cloaking-rules.txt

    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.

  [auth key]: https://tailscale.com/kb/1085/auth-keys

.env

    TS_AUTH_KEY=tskey-auth-xxxxxxxxxxx

Enabling on Linux Mint

My tailnet uses Magic DNS which sets the nameserver for all devices on
a tailnet to 100.100.100.100, 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.

  [Magic DNS]: https://tailscale.com/kb/1081/magicdns

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

    nameserver 100.112.129.40
    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  @100.76.233.91 (where
the IP is your Tailscale container IP) and check the logs for messages
similar to 127.0.0.1       m.youtube.com   A       CLOAK   0ms     -.