In a Nutshell
RouterOS script that uses CoreDNS to:
- Prevent leaks of queries for domains in Locally-Served DNS zones
- Set up Comprehensive A/AAAA/PTR resource records for hosts
- Set up PTR/SRV/TXT resource records for Wide-Area DNS-Based Service Discovery
Introduction
RouterOS's DNS Resolver is a very basic DNS Proxy. DNS-over-HTTPS and a very limited number of supported static resource records
is pretty much all it can do. You cannot setup Wide-Area DNS-Based Service Discovery (aka Wide-Area Bonjour),
it leaks queries for domains in IANA's Locally-Served DNS Zones, doesn't support Access Control Lists, Split-Horizon DNS, etc.
In this article I show how the HomenetDNS script and CoreDNS container address these shortcomings via plugins with custom zone files.
Why CoreDNS?
Designed specifically for a containeraized deployment, its adjustable plugin architecture allows for a very slim image. The container I use is only 5MB! Everything being compiled into a single binary makes it very easy to build and deploy. Configuration is approachable
even if you have limited knowledge of DNS. Pipeline-like processing of queries is easy to reason about.
Installation
HomenetDNS requires routeros-scripts being installed on the router. You will need a container engine (such as Docker) to prepare the CoreDNS container image.
In this setup DNS resolution looks like this:
Code: Select all
Host -> HomenetDNS Resolver -> RouterOS Resolver
HomenetDNS Resolver is set up on hosts (via DHCPv4 and RAs), but it only answers queries for domains in locally-served zones and configured domains. The rest is forwarded to RouterOS which also handles caching.
See [configuration examples](#configuration-examples) for other deployment scenarios.
Prepare CoreDNS Container
Code: Select all
FROM --platform=$BUILDPLATFORM golang:1.23.8 AS build
WORKDIR /src
ADD https://github.com/coredns/coredns.git#v1.12.1 /src
COPY <<EOF /src/plugin.cfg
reload:reload
errors:errors
log:log
cache:cache
rewrite:rewrite
auto:auto
forward:forward
template:template
EOF
RUN sh -c 'GOFLAGS="-buildvcs=false" make gen && GOFLAGS="-buildvcs=false" make'
FROM --platform=$TARGETPLATFORM gcr.io/distroless/static-debian12
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /src/coredns /bin/coredns
USER root
WORKDIR /etc/coredns
EXPOSE 53 53/udp
ENTRYPOINT ["/bin/coredns"]
This is mainly based on CoreDNS's Dockerfile. Few important differences:
- plugin.cfg: List of plugins is trimmed to reduce binary size and the order of plugins (i.e. order of query processing) is altered from rewrite->template->auto->forward to rewrite->auto->forward->template, this allows to handle queries of domains in locally-served zones (via auto) before they are forwarded to the upstream
- USER: /bin/coredns is run by root, this is RouterOS's requirement to bind on the 53 privileged port
- WORKDIR: changed to /etc/coredns, this is where a directory with the HomenetDNS configuration is mounted at
Build, export and upload the image:
Code: Select all
$ docker build -t routeros_coredns:latest --platform linux/arm64 -f Dockerfile .
$ docker save routeros/coredns:latest | gzip > routeros_coredns.tar.gz
$ scp routeros_coredns.tar.gz <router-ip>:/
Install Container on RouterOS
Add the network interface:
Code: Select all
> /interface/veth/add address=192.0.2.53/31,2001:db8:53::1/127 gateway=192.0.2.52 gateway6=2001:db8:53:: name=veth-coredns
> /ip/address/add address=192.0.2.52/31 interface=veth-coredns
> /ipv6/address/add address=2001:db8:53::/127 advertise=no interface=veth-coredns no-dad=yes
When picking IP addresses for the container, consider:
- Does the container need to be dual-stack and run on both IPv4 and IPv6?
- Does the container need access to WAN?
Then add a mount for the directory where HomenetDNS generates zones files and CoreDNS configuration. It's best to use an attached disk for the mount:
Code: Select all
> /container/mounts/add dst=/etc/coredns/ name=coredns src=/usb1-part2/coredns/config
Finally, add the container using from the built image:
Code: Select all
> /container/add file=routeros_coredns.tar.gz interface=veth-coredns root-dir=usb1-part2/coredns/root mounts=coredns workdir=/ logging=yes start-on-boot=yes
Install HomenetDNS
Download the scripts:
Code: Select all
> $ScriptInstallUpdate ({
"mod/kentzo-functions";
"mod/ipv4-structured";
"mod/ipv6-structured";
"mod/homenet-dns";
"setup-homenet-dns";
}) "base-url=https://raw.githubusercontent.com/Kentzo/routeros-scripts-custom/main/"
Optionally, schedule to re-generate zone files daily:
Code: Select all
> /system/scheduler/add name=update-homenet-dns interval=24h start-time=03:00:00 on-event=setup-homenet-dns policy=read,write,sensitive
Usage
Refer to the mod/homenet-dns.rsc for the up-to-date configuration reference.
HomenetDNS is configured by setting the HomenetDNSConfig in the global-config-overlay script:
Code: Select all
:global HomenetDNSConfig ({
# (str): Regex-escaped unique ID of the managed objects
"managedID"="01234567-1337-dead-beef-0123456789ab";
# (str): Name of the CoreDNS container
"nsContainer"="...";
# (str): Optional path to the CoreDNS working directory that will be mounted into the container; defaults to the container's first mount
# "nsRoot"="";
# (ip, str): Optional IPv4 address of the DNS server in zone files; defaults to the container's first IPv4 address
# "nsIPAddress"=;
#(ip, str): Optional IPv6 address of the DNS server in zone files; defaults to the container's first IPv6 address
# "nsIP6Address"=;
# (str): Optional default domain name for hosts and services; defaults to global-config's $Domain, if present, otherwise "home.arpa."
# "domain"="home.arpa.";
# (num): Optional TTL in seconds for DNS resource records; defaults to 3600
# "ttl"=3600;
# (array): Optional array of hosts for address resource records; defaults to empty
# - name: Hostname (and subdomain) relative to the "domain"
# - [domain]: Optional domain of the host; defaults to config's "domain"
# - addresses: An array of IPv4, IPv6 or MAC addresses
# "hosts"={
# {"name"="gateway" ; "addresses"={192.0.2.1 ; 2001:db8::1}};
# };
# (array): Optional array of DNS-SD services (RFC 6763); defaults to empty
# - name: <Instance> of Service Instance Name, encoded and escaped, e.g. "Home\\ Media"
# - service: <Service> of Service Instance Name, e.g. "_smb._tcp"
# - [domain]: Optional <Domain> of Service Instance Name, e.g. "home.arpa."; defaults to config's "domain"
# - host: Hostname that provides service, e.g. "gateway" (relative to "domain") or "gateway.example." (absolute)
# - port: Port on the "host" where the service is available, e.g. "445"
# - [txt]: Optional TXT record(s) associated with the service instance
# - {str}: one TXT record with multiple values, e.g. {"path"="/usb1-part2/media" ; "u=guest"} -> TXT ("path=/usb1-part2/media" "u=guest")
# - {{str}}: Multiple TXT records where each follows the rule above
# "services"={
# {"name"="Home\\ Media" ; "service"="_smb._tcp" ; "host"="gateway" ; "port"=445 ; txt={"path=/media" ; "u"="guest"}};
# };
# (bool): Optional flag to control whether /ip/dns/forwdarder for all configured zones is set up; defaults to `/ip/dns/get value-name=allow-remote-requests`
# "useDNSForwarder"=yes;
# (bool, num, time): Optional flag or refresh interval to control whether CoreDNS uses zero-downtime deployment; defaults to no
# - yes or >0 interval causes CoreDNS to re-read configuration and zones every so often; defaults to 60s
# - no or <=0 interval disables zero-downtime behavior; instead the container gets restarted on changes
# "useZeroDowntime"=no;
# (str): Optional regex to filter IPv4 ARP when resolving hosts; defaults to "(permanent|reachable|stale)"
# "ipARPStatusRegex"="(permanent|reachable|stale)";
# (str): Optional regex to filter IPv6 neighbors when resolving hosts; defaults to "(noarp|reachable|stale)"
# "ip6NeighborStatusRegex"="(noarp|reachable|stale)";
# (str): Optional regex to filter interfaces when resolving addresses of hosts and delegated networks; defaults to ".*"
# "interfacesRegex"=".*";
# (array): Optional array of additional IPv4 networks delegated to the router; defaults to advertised prefixes
# "ipNetworksExtra"={};
# (array): Optional array of additional IPv6 networks delegated to the router; defaults to delegated and advertised prefixes
# "ip6NetworksExtra"={};
# (array): Optional array of additional domains delegated to the router
# "domainsExtra"={};
# (array): Optional array of additional resource records
# - key: Zone domain name
# - value: An array of additional resource records to append to the zone file
# "zonesExtra"={
# "example."={
# "samba CNAME gateway";
# }
# };
# (str): Optional additional CoreDNS configuration for the main server block, passed verbatim
# "corefileExtra"="";
# (str): Optional CoreDNS configuration override, passed verbatim; contents of the default main server block is available via the "homenet-dns-default" snippet
# "corefileOverride"="";
})
You start by setting managedID and nsContainer which completes basic CoreDNS setup that authoritively answers queries for domains in locally-served zones with the NXDOMAIN rcode.
For each record in hosts you provide a hostname and at least one IP address (at most one IPv4 and one IPv6 will be used for A and AAAA resource records, respectively). The script does one extra step: it uses these IPs to look up MACs and then uses these MACs to look up all IPs assigned to the host. This comprehensive list of addresses populates PTR resource records. The search can be assisted by [providing additional IPs and MACs](#address-resource-records) in the array. The ipARPStatusRegex, ip6NeighborStatusRegex and interfacesRegex options further narrow down which addresses and interfaces are considered.
services is interesting. macOS (iOS, iPadOS, tvOS), Linux distros and Windows all support DNS-Based Service Discovery. Most of the time it happens over multicast via mDNS, but unicast DNS is also supported via specially crafted PTR, SRV and TXT resource records. Very handy when a service (say, an SMB share) on a separate VLAN, but you still want apps on your devices to find it automatically. Just set it here and let the magic happen. The dns-sd -B command on macOS and avahi-browse command on Linux can help discover available services.
useDNSForwarder configures a DNS forwarder on RouterOS for zones handled by HomenetDNS.
The script is conservative when it comes to disk usage. Each run only reads one file (< 100KB) and writes are avoided unless a change is detected. CoreDNS's zero-downtime deployment is disabled by default and the container is restarted instead. Set the useZeroDowntime option to yes or a positive interval in seconds to avoid downtime by make CoreDNS to re-read configuration every so often.
In addition to locally-served zones the script considers IPv6 prefixes advertised by Neighbor Discovery as well as managed by DHCPv6 Server. Additional networks and domains can be provided via the ipNetworksExtra, ip6NetworksExtra and domainsExtra options.
Finally, if generated Corefile is not to your liking:
- corefileExtra: add additional plugins to the main server block
- corefileOverride: completely override Corefile which useful when you need additional server blocks for Split-Horizon DNS
Configuration Examples
Authoritative-only Server
Code: Select all
-> HomenetDNS Server
|
Host -> RouterOS Resolver ->
|
-> Internet DNS Server
Code: Select all
:global HomenetDNSConfig {
"managedID"="...";
"nsContainer"="...";
"useDNSForwarder"=yes;
"hosts"={
{"name"="gateway" ; "addresses"={192.0.2.1 ; 2001:db8::1}};
};
"services"={
{"name"="Media" ; "service"="_smb._tcp" ; "host"="samba" ; "port"=445 ; txt={"path=/media" ; "u"="guest"}};
};
"zonesExtra"={
"example."={
"samba CNAME gateway";
};
};
}
Hosts query RouterOS which in turn decides whether to use HomenetDNS or forward to an internet DNS server. HomenetDNS configures a DNS forwarder, queries for domains in its own zones are answered authoritatively, all other queries get the REFUSED rcode for compatibility.
For a 3rd party resolver set "useDNSForwarder"=no and configure forwarding manually.
Authoritative Server and DNS Forwarder
Code: Select all
Host -> HomenetDNS Resolver -> RouterOS Resolver
Code: Select all
:global HomenetDNSConfig {
"managedID"="...";
"nsContainer"="...";
"useDNSForwarder"=no;
"hosts"={
{"name"="gateway" ; "addresses"={192.0.2.1 ; 2001:db8::1}};
};
"services"={
{"name"="Media" ; "service"="_smb._tcp" ; "host"="samba" ; "port"=445 ; txt={"path=/media" ; "u"="guest"}};
};
"zonesExtra"={
"example."={
"samba CNAME gateway";
};
};
"corefileExtra"="\
forward . dns://<router-ip-on-veth-coredns> {\n\
max_fails 0\n\
}\n\
";
}
Hosts query HomenetDNS. Queries for domains in its own zones are answered authoritatively, all other queries are forwarded to RouterOS which in turn forwards to the Internet resolver and handles caching.
Code: Select all
Host -> HomenetDNS Resolver -> Internet Resolver
Code: Select all
:global HomenetDNSConfig {
"managedID"="...";
"nsContainer"="...";
"useDNSForwarder"=no;
"hosts"={
{"name"="gateway" ; "addresses"={192.0.2.1 ; 2001:db8::1}};
};
"services"={
{"name"="Media" ; "service"="_smb._tcp" ; "host"="samba" ; "port"=445 ; txt={"path=/media" ; "u"="guest"}};
};
"zonesExtra"={
"example."={
"samba CNAME gateway";
};
};
"corefileExtra"="\
cache\n\
forward . https://dns.quad9.net/dns-query\n\
";
}
Similar to the configuration above but this time HomenetDNS forwards request to the Internet resolver and handles caching.
Address Resource Records
Add address resources records for hosts":
Code: Select all
:global HomenetDNSConfig {
...
"hosts"={
# Both A and AAAA resource records for the gateway host in the default domain
{"name"="gateway" ; "addresses"={192.0.2.1 ; 2001:db8::1}};
# Same as above + additional MAC address to create PTR resource records
{"name"="gateway" ; "addresses"={192.0.2.1 ; 2001:db8::1 ; "00:53:00:AA:BB:CC"}};
# One A resource record for 192.0.2.1; remaining addresses are used to create PTR resource records
{"name"="gateway" ; "addresses"={192.0.2.1 ; 10.0.0.1 ; 192.168.0.1}};
# A and AAAA resource records for gateway.example. rather than in the default domain
{"name"="gateway" ; "domain"="example." ; "addresses"={192.0.2.1 ; 2001:db8::1}};
};
...
}
Narrow down which interfaces are considered:
Code: Select all
:global HomenetDNSConfig {
...
"hosts"={ ... };
# Only consider the vlan-main and vlan-services interfaces when making PTR resource records
"interfacesRegex"="(vlan-main|vlan-services)";
...
}
DNS-SD Resource Records
Advertise the "Home Media._smb._tcp" samba share and "Printer._ipp._tcp" printer via unicast DNS:
Code: Select all
:global HomenetDNSConfig ({
...
"services"=({
# Shows as "Home Media" in apps that can access SMB shares
{"name"="Home\\ Media" ; "service"="_smb._tcp" ; "host"="gateway" ; "port"=445 ; txt={"path=/media" ; "u"="guest"}};
# Shows as "Printer" in the printing dialogs of apps that support AirPrint
{"name"="Printer" ; "service"="_ipp._tcp" ; "host"="printer" ; "port"=631 ; txt=({
"txtvers=1";
"qtotal=1";
"rp=printers/HP_Color_LaserJet_9500";
"ty=HP Color LaserJet 9500 MFP";
"adminurl=...";
"note=Shared HP CLJ 9500; In DA7/4 Near Howard";
"priority=0";
"product=(HP color LaserJet 9500 MFP)";
"printer-state=3";
"printer-type=0xC0B0DE";
"Transparent=T";
"Binary=T";
"Fax=F";
"Color=T";
"Duplex=T";
"Staple=F";
"Copies=T";
"Collate=T";
"Punch=F";
"Bind=F";
"Sort=F";
"Scan=F";
"pdl=application/octet-stream,application/pdf,application/postscript,image/jpeg,image/png,image/urf";
"air=username,password";
"URF=W8,SRGB24,CP255,RS600,DM1";
})};
});
...
})
Verbatim Resource Records
Add the CNAME resource record to redirect samba.example. -> gateway.example.:
Code: Select all
:global HomenetDNSConfig ({
...
"zonesExtra"=({
# Add the CNAME resource record to the example. zone
"example."={
"samba CNAME gateway";
}
});
...
})
Verbatim CoreDNS Configuration
Enable caching and verbose logging:
Code: Select all
:global HomenetDNSConfig ({
...
# Customize the main server block
"corefileExtra": "\
log\n\
cache\n\
"
...
})
Use Split-Horizon DNS for custom processing of queries from the 10.13.37.0/24 subnet:
Code: Select all
:global HomenetDNSConfig ({
...
# Add Split-Horizon DNS with custom processing for hosts from 10.13.37.0/24
"corefileOverride": "\
. {\n\
view IPsec {\n\
expr incidr(client_ip(), '10.13.37.0/24')\n\
}\n\
...\n\
}\n\
\n\
. {\n\
import homenet-dns-default\n\
}\n\
"
})
Normative and Informational References
- Locally-Served DNS Zones <https://www.iana.org/assignments/locall ... ones.xhtml>
- [RFC 9499] DNS Terminology <https://datatracker.ietf.org/doc/html/rfc9499>
- [RFC 1035] DOMAIN NAMES - IMPLEMENTATION AND SPECIFICATION <https://datatracker.ietf.org/doc/html/rfc1035>
- [RFC 6762] Multicast DNS <https://datatracker.ietf.org/doc/html/rfc6762>
- [RFC 6763] DNS-Based Service Discovery <https://datatracker.ietf.org/doc/html/rfc6763>
- CoreDNS Manual <https://coredns.io/manual/toc/>
- CoreDNS Plugins <https://coredns.io/plugins/>
- How Queries Are Processed in CoreDNS <https://coredns.io/2017/06/08/how-queri ... n-coredns/>
- Compile Time Enabling or Disabling Plugins <https://coredns.io/2017/07/25/compile-t ... g-plugins/>
- DNS Service Discovery (DNS-SD) <http://www.dns-sd.org>
- Manually Adding DNS-SD Service Discovery Records to an Existing Authoritative Name Server <http://www.dns-sd.org/serverstaticsetup.html>
- Service Name and Transport Protocol Port Number Registry <https://www.iana.org/assignments/servic ... bers.xhtml>
- Bonjour Printing Specification <https://developer.apple.com/bonjour/pri ... -1.2.1.pdf>
- RouterOS Scripting <https://help.mikrotik.com/docs/spaces/R ... /Scripting>
- Christian Hesse's routeros-scripts <https://git.eworm.de/cgit/routeros-scripts/about/>