Internal DNS Names With WireGuard

Using raw WireGuard peer IP addresses to connect to internal resources on your WireGuard network can be difficult for regular users. Fortunately, it’s easy to set up an internal DNS server that allows your users to access resources exposed through WireGuard with friendly DNS names.

For example, say you have an internal chat server set up with a WireGuard peer IP address of 10.0.0.11 on your WireGuard network. Instead of requiring your users enter its IP address of 10.0.0.11 in their chat application or web browser to access it, you can configure your internal DNS server to use a friendly DNS name like chat.wg.corp for it.

This article will show you how to set up a CoreDNS server on the hub of a WireGuard hub-and-spoke network, allowing internal DNS names resolved by the CoreDNS server to be used to access network resources through the hub. We’ll follow these steps:

Example Network

We’ll use the following WireGuard network as an example, configured like the Site Gateway as a Spoke scenario of the Multi-Hop WireGuard article:

Hub with a Site Gateway Spokes

The central WireGuard Hub (configured like Host C from the “Site Gateway as a Spoke” scenario) allows a WireGuard Client (configured like Endpoint A from the “Site Gateway as a Spoke” scenario) to connect to the NY Office network through the NY Gateway (configured like Host β from the “Site Gateway as a Spoke” scenario); and also to the Engineering Cloud network through the Eng Gateway, and to other peers on the WireGuard network like the Chat Server.

We’ll set up a CoreDNS server on the Hub so that a user using the Client can access the Chat Server via a friendly DNS name of chat.wg.corp, instead of its WireGuard IP address of 10.0.0.11; and access the NY Office Printer via a friendly DNS name of printer.ny.corp, instead of its LAN IP address of 192.168.200.22; and access the engineering Fileserver via a friendly DNS name of files.eng.corp, instead of its Engineering Cloud network IP address of 10.10.10.43.

For the Fileserver, we’ll have the CoreDNS server on the WireGuard Hub delegate to a private DNS server running in cloud network at 10.0.0.44 that’s already set up to resolve DNS names for the eng.corp domain. For the Chat Server and the Printer, we’ll configure our CoreDNS server itself to resolve DNS names for the wg.corp and ny.corp domains.

Disable Stub Resolver

The first step to running CoreDNS on the Hub is to turn off any existing DNS server on the Hub (to free up port 53).

If you’re running systemd on the Hub, you’ll need to disable systemd’s own stub resolver. Edit the /etc/systemd/resolved.conf file, and set its DNSStubListener field to no:

DNSStubListener=no

Then apply your changes by running the following command:

$ sudo systemctl restart systemd-resolved
Tip

If you want the Hub itself to use CoreDNS for its own DNS lookups, also modify the DNS and Domains settings of the /etc/systemd/resolved.conf file to the following:

DNS=127.0.0.1
Domains=~.

But make sure to change these two settings only after you’ve finished installing and configuring CoreDNS, and have verified that it’s resolving external domain names successfully.

Install CoreDNS

CoreDNS runs as single executable. You can simply download the latest release archive from the CoreDNS releases page on GitHub, extract the archive (which contains a single executable file), set permissions on the executable, and run it. Alternatively, you can pull the latest CoreDNS Docker image to run CoreDNS as a Docker container; or you can build a deb or RPM file to install CoreDNS as a systemd service on Debian- or Fedora-based Linux distributions.

Via Docker

You can launch CoreDNS via Docker Compose using the following docker-compose.yml file:

# /srv/coredns/docker-compose.yml
coredns:
  image: coredns/coredns
  command: -conf /etc/coredns/Corefile
  ports:
  - 53:53/udp
  - 53:53/tcp
  volumes:
  - ./conf:/etc/coredns

For example, create a /srv/coredns/ directory on the Hub, and place the above docker-compose.yml file in it. Then create a conf/ subdirectory of /srv/coredns/, and place the following Corefile into it:

# /srv/coredns/conf/Corefile
. {
  whoami
  log
}

Start up Docker Compose from the /srv/coredns directory:

$ cd /srv/coredns
$ sudo docker-compose up
Pulling coredns (coredns/coredns:)...
latest: Pulling from coredns/coredns
9731739b2823: Pull complete
4dfb45b72a09: Pull complete
Digest: sha256:017727efcfeb7d053af68e51436ce8e65edbc6ca573720afb4f79c8594036955
Status: Downloaded newer image for coredns/coredns:latest
Creating coredns_coredns_1 ... done
Attaching to coredns_coredns_1
coredns_1  | .:53
coredns_1  | CoreDNS-1.10.0
coredns_1  | linux/arm64, go1.19.1, 596a9f9

Test it out by running the following command on the Hub:

$ dig @127.0.0.1 example.com

; <<>> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> @127.0.0.1 example.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 22048
;; flags: qr aa rd; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 3
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: 9b3888d9496d270e (echoed)
;; QUESTION SECTION:
;example.com.                   IN      A

;; ADDITIONAL SECTION:
example.com.            0       IN      A       172.17.0.1
_udp.example.com.       0       IN      SRV     0 0 41596 .

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1) (UDP)
;; WHEN: Thu Jan 19 20:13:03 UTC 2023
;; MSG SIZE  rcvd: 114

Because we’ve configured CoreDNS with the whoami plugin (for testing purposes), the response in the “additional section” will show the IP address (172.17.0.1) and UDP port (41596) from which you queried CoreDNS — not the actual DNS information of example.com.

You’ll see a corresponding log entry for the query in CoreDNS’s output:

coredns_1  | [INFO] 172.17.0.1:41596 - 22048 "A IN example.com. udp 52 false 1232" NOERROR qr,aa,rd 91 0.000098518s

Via Deb

On Debian (or a Debian-based Linux distribution like Ubuntu), you can install CoreDNS through a deb package. This will set up a convenient systemd service for you.

First, clone the CoreDNS Deployment repo:

$ git clone https://github.com/coredns/deployment.git coredns/deployment
Cloning into 'coredns/deployment'...
remote: Enumerating objects: 970, done.
remote: Counting objects: 100% (111/111), done.
remote: Compressing objects: 100% (70/70), done.
remote: Total 970 (delta 57), reused 70 (delta 35), pack-reused 859
Receiving objects: 100% (970/970), 270.48 KiB | 8.20 MiB/s, done.
Resolving deltas: 100% (525/525), done.

Enter the cloned repo, and run the following command to build a deb of the latest CoreDNS release:

$ cd coredns/deployment
$ dpkg-buildpackage -us -uc -b
Command 'dpkg-buildpackage' not found, but can be installed with:
sudo apt install dpkg-dev

You may have to install the following dependencies to successfully build the deb:

$ sudo apt install dpkg-dev debhelper jq
Reading package lists... Done
...
$ dpkg-buildpackage -us -uc -b
dpkg-buildpackage: info: source package coredns
...

After the build command succeeds, navigate up a directory, and you should have a brand new CoreDNS deb package:

$ cd ..
ls -1
coredns_0-0_arm64.buildinfo
coredns_0-0_arm64.changes
coredns_1.10.0-0~22.040_arm64.deb
deployment

Install the deb with the following command:

$ sudo dpkg -i coredns*.deb
Selecting previously unselected package coredns.
(Reading database ... 71988 files and directories currently installed.)
Preparing to unpack coredns_1.10.0-0~22.040_arm64.deb ...
Unpacking coredns (1.10.0-0~22.040) ...
Setting up coredns (1.10.0-0~22.040) ...
Created symlink /etc/systemd/system/multi-user.target.wants/coredns.service → /lib/systemd/system/coredns.service.
Processing triggers for man-db (2.10.2-1) ...

This will install CoreDNS as a systemd service, listening on UDP and TCP port 53. By default, it will be configured with a test Corefile similar to the one we used for the Docker container above:

$ cat /etc/coredns/Corefile
# Default Corefile, see https://coredns.io for more information.

# Answer every below the root, with the whoami plugin. Log all queries
# and errors on standard output.
. {
    whoami  # coredns.io/plugins/whoami
    log     # coredns.io/plugins/log
    errors  # coredns.io/plugins/errors
}

And we’ll see a similar result as with the Docker container above if we run a test query against it:

$ dig @127.0.0.1 example.com
...
;; ADDITIONAL SECTION:
example.com.            0       IN      A       127.0.0.1
_udp.example.com.       0       IN      SRV     0 0 39581 .
...

And we’ll see similar log output from journald for it:

$ journalctl -u coredns.service
Jan 19 20:33:57 hub systemd[1]: Started CoreDNS DNS server.
Jan 19 20:33:57 hub coredns[42762]: .:53
Jan 19 20:33:57 hub coredns[42762]: CoreDNS-1.10.0
Jan 19 20:33:57 hub coredns[42762]: linux/arm64, go1.19.1, 596a9f9
Jan 19 20:37:01 hub coredns[42762]: [INFO] 127.0.0.1:39581 - 58758 "A IN example.com. udp 52 false 1232" NOERROR qr,aa,rd 91 0.024847392s

Configure CoreDNS

Now we can update the test Corefile (at /srv/coredns/conf/Corefile if you installed Via Docker, or /etc/coredns/Corefile if you installed Via Deb) with some useful configuration for our internal domains.

Via Inline Hosts

The simplest way to have CoreDNS resolve our internal DNS names is to list them out with the traditional /etc/hosts file format inside the Corefile itself. For example, we can list the three DNS names we want to resolve (chat.wg.corp, printer.ny.corp, and files.eng.corp) and their respective IP addresses directly in our Corefile:

# /etc/coredns/Corefile
. {
  hosts {
    10.0.0.11 chat.wg.corp
    192.168.200.22 printer.ny.corp
    10.10.10.43 files.eng.corp
  }
  errors
}

If we update our Corefile to the above and restart CoreDNS, CoreDNS can now resolve those three DNS names — but only those three DNS names:

$ dig +short @127.0.0.1 chat.wg.corp
10.0.0.11
$ dig +short @127.0.0.1 printer.ny.corp
192.168.200.22
$ dig +short @127.0.0.1 files.eng.corp
10.10.10.43
$ dig +short @127.0.0.1 repo.eng.corp
$ dig +short @127.0.0.1 example.com

What we really want to do for all the domains not listed in our Corefile is forward all eng.corp queries to the Eng DNS server at 10.10.10.44, and forward all other queries to a public DNS server like Quad9. We can do that by adjusting our Corefile to add a fallthrough setting to the hosts plugin, plus add two forward plugins — one for our Eng DNS resolver, and the other for the Quad9 resolvers:

# /etc/coredns/Corefile
. {
  hosts {
    10.0.0.11 chat.wg.corp
    192.168.200.22 printer.ny.corp
    fallthrough
  }
  forward eng.corp 10.10.10.44
  forward . 9.9.9.9 149.112.112.112
  errors
}

Restart CoreDNS, and now we can resolve both the hardcoded DNS names from our hosts plugin, as well as any DNS name from our private Eng DNS server (like repo.eng.corp) — and all public domains (like example.com), too:

$ dig +short @127.0.0.1 chat.wg.corp
10.0.0.11
$ dig +short @127.0.0.1 printer.ny.corp
192.168.200.22
$ dig +short @127.0.0.1 files.eng.corp
10.10.10.43
$ dig +short @127.0.0.1 repo.eng.corp
10.10.10.49
$ dig +short @127.0.0.1 example.com
93.184.216.34
Tip

You can selectively “overwrite” public DNS entries with CoreDNS host files — just like you can with the /etc/hosts file on your local computer. For example, with the following Corefile, we could make www.example.com resolve to 10.0.0.11:

# /etc/coredns/Corefile
. {
  hosts {
    10.0.0.11 www.example.com
    fallthrough
  }
  forward . 9.9.9.9 149.112.112.112
  errors
}

With the above configuration, when using CoreDNS as our DNS resolver (like it will be when a client’s WireGuard interface is up), www.example.com will resolve to an internal WireGuard IP address of 10.0.0.11; and when not using CoreDNS (like it will be when the same client’s WireGuard interface is shut down), www.example.com will resolve to its normal public IP address:

$ dig +short @127.0.0.1 www.example.com
10.0.0.11
$ dig +short @9.9.9.9 www.example.com
93.184.216.34

Via Single Hosts File

Instead of defining custom DNS names directly in our Corefile, we can pull them out into a separate file. The advantage of this is a) we could use the Hub’s own /etc/hosts file directly if we wanted, and b) we can can configure CoreDNS to periodically check this file for updates (instead of having to restart CoreDNS every time we make a change to a DNS name).

For example, we could pull out our custom DNS names into a file called /etc/coredns/hosts:

# /etc/coredns/hosts
10.0.0.11 chat.wg.corp
192.168.200.22 printer.ny.corp

And then update our Corefile to load DNS names from it (and reload it every 60 seconds):

# /etc/coredns/Corefile
. {
  hosts /etc/coredns/hosts {
    reload 60s
    fallthrough
  }
  forward eng.corp 10.10.10.44
  forward . 9.9.9.9 149.112.112.112
  errors
}

Via Separate Hosts Files

We can also pull out separate domains into separate host files, for our own administrative convenience. For example, we could list our wg.corp DNS entries in an /etc/coredns/hosts/wg.corp file, and our ny.corp DNS entries in an /etc/coredns/hosts/ny.corp file:

# /etc/coredns/hosts/wg.corp
10.0.0.11 chat.wg.corp
# /etc/coredns/hosts/ny.corp
192.168.200.22 printer.ny.corp

The hosts plugin can only be used once per top-level block in a Corefile however, so we now have to structure our Corefile with separate top-level blocks for our wg.corp and ny.corp domains (and if we do that, we might as well also pull our eng.corp domain into its own separate top-level block, as well):

# /etc/coredns/Corefile
wg.corp {
  hosts /etc/coredns/hosts/wg.corp {
    reload 60s
  }
  errors
}
ny.corp {
  hosts /etc/coredns/hosts/ny.corp {
    reload 60s
  }
  errors
}
eng.corp {
  forward . 10.10.10.44
  errors
}
. {
  forward . 9.9.9.9 149.112.112.112
  errors
}

Via Separate Zone Files

We can also use traditional RFC 1035-style zone files to define our custom DNS entries. This allows us to take full advantage of DNS features, like using CNAME or SRV records, or applying different TTL (Time To Live) values to different DNS entries.

For example, we could create the following zone file for the wg.corp domain:

; /etc/coredns/zones/db.wg.corp
$ORIGIN wg.corp.
$TTL 1h
@                      IN SOA (
                          ns          ; primary nameserver
                          .           ; zone-admin email
                          1           ; serial number
                          24h         ; refresh interval
                          2h          ; retry interval
                          1000h       ; expire interval
                          10m         ; negative TTL
                       )
                       IN NS ns
chat                   IN A 10.0.0.11
hub                    IN A 10.0.0.3
ns                  5m IN A 10.0.0.3
_irc._tcp              IN SRV 10 10 6667 chat.wg.corp.

And the following zone file for the ny.corp domain:

; /etc/coredns/zones/db.ny.corp
$ORIGIN ny.corp.
$TTL 1h
@                      IN SOA (
                          ns.wg.corp. ; primary nameserver
                          .           ; zone-admin email
                          1           ; serial number
                          24h         ; refresh interval
                          2h          ; retry interval
                          1000h       ; expire interval
                          10m         ; negative TTL
                       )
                       IN NS ns.wg.corp.
canon650i              IN A 192.168.200.22
printer                IN CNAME canon650i
Tip

A few important things to remember when editing zone files:

  • CoreDNS requires each zone to have a valid SOA record (even though the only SOA fields that really matter for our use case are the serial number and negative TTL).

  • Increment the serial number of each zone file every time you update it.

  • Include a trailing . when specifying fully-qualified domain names.

  • Keep your TTLs low until you’ve tested everything out.

If we placed the two above files in the /etc/coredns/zones directory as db.wg.corp and db.ny.corp, we can apply them via CoreDNS’s auto plugin:

# /etc/coredns/Corefile
. {
  auto {
    directory /etc/coredns/zones
    reload 60s
  }
  forward eng.corp 10.10.10.44
  forward . 9.9.9.9 149.112.112.112
  errors
}
Tip

CoreDNS’s auto plugin by default requires zone files to be named with a pattern like db.{origin}, where {origin} is the zone’s domain name — like db.wg.corp for a zone file where wg.corp is the domain name. If you want to name your zone files differently, see the auto plugin’s documentation for details on how to set up a custom file pattern; or use the file plugin instead of the auto plugin to specify each zone file individually.

After restarting, we should be able to lookup our IRC (Internet Relay Chat) SRV record:

$ dig @127.0.0.1 SRV _irc._tcp.wg.corp

; <<>> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> @127.0.0.1 SRV _irc._tcp.wg.corp
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 60358
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 1, ADDITIONAL: 2
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: 50115fc0e15cc095 (echoed)
;; QUESTION SECTION:
;_irc._tcp.wg.corp.             IN      SRV

;; ANSWER SECTION:
_irc._tcp.wg.corp.      3600    IN      SRV     10 10 6667 chat.wg.corp.

;; AUTHORITY SECTION:
wg.corp.                3600    IN      NS      ns.wg.corp.

;; ADDITIONAL SECTION:
chat.wg.corp.           3600    IN      A       10.0.0.11

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1) (UDP)
;; WHEN: Thu Jan 19 20:20:38 UTC 2023
;; MSG SIZE  rcvd: 166

As well as the CNAME record for the NY Office Printer:

$ dig @127.0.0.1 printer.ny.corp

; <<>> DiG 9.18.1-1ubuntu1.2-Ubuntu <<>> @127.0.0.1 printer.ny.corp
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 29728
;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 1, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: 71a27f43c8748c85 (echoed)
;; QUESTION SECTION:
;printer.ny.corp.               IN      A

;; ANSWER SECTION:
printer.ny.corp.        3600    IN      CNAME   canon650i.ny.corp.
canon650i.ny.corp.      3600    IN      A       192.168.200.22

;; AUTHORITY SECTION:
ny.corp.                3600    IN      NS      ns.wg.corp.

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1) (UDP)
;; WHEN: Thu Jan 19 20:20:49 UTC 2023
;; MSG SIZE  rcvd: 166

Configure WireGuard Client

Now that we have our CoreDNS server up and running, the only thing left to do is configure each WireGuard client to use it as its DNS server when the WireGuard interface is started up.

Since the CoreDNS server is running on our WireGuard Hub, we’ll use the Hub’s WireGuard IP address (10.0.0.3) to identify it. We simply have to add this IP address as the DNS setting to the [Interface] section of each WireGuard client’s config:

# /etc/wireguard/wg0.conf

# local settings for the WireGuard Client
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/32
ListenPort = 51821
DNS = 10.0.0.3

# remote settings for the WireGuard Hub
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
Endpoint = 192.0.2.3:51823
AllowedIPs = 10.0.0.0/24, 10.10.10.0/24, 192.168.200.0/24
Important

Make sure the client has an AllowedIPs setting that includes the DNS server’s IP address. For example, in the above configuration file, the DNS server’s IP address of 10.0.0.3 is included in the 10.0.0.0/24 block of IPs allowed for the client’s Hub peer.

Tip

If the client is using Linux with systemd, use the following PostUp script instead of the DNS setting:

PostUp = resolvectl dns %i 10.0.0.3; resolvectl domain %i ~corp

See the WireGuard DNS Configuration for Systemd article for details.

Restart WireGuard on the client after making this change.

Now we should be able to use our new friendly DNS names on the WireGuard Client:

$ dig +short chat.wg.corp
10.0.0.11