mDNS "subdomains"

2024-03-18

For a long time I've run a couple small networked services for use at home off a Raspberry Pi. Recently, however, I upgraded my work laptop, a Framework 13, and had the opportunity to purchase the old mainboard from my employer, an 11th Gen Intel i7 board. There were a few things I wanted to run at home that a Raspberry Pi just wasn't capable of handling, so I jumped at this opportunity, and ordered the Framework mainboard enclosure as well. Over the last week or so I've started setting up a few of these services that I wanted to have before, and that's been a lot of fun, particularly as some of them are interconnected, and I love "wiring things up" in that sense.

When I was only running one or two things, (usually just pi-hole), I didn't bother much with making my home mDNS setup work nicely. I was fine remembering the one or two ports I needed to keep track of, or relying on browser history and copy/pasting from there when necessary. As I've added a few more things, though, that's become pretty tedious. With Avahi up and running, I ignorantly thought I'd be good to go with "subdomains" on the mDNS route for my home server. I added an entry to my Caddyfile on the server like this:

http://service.server.local {
    reverse_proxy localhost:1234
}

But, whoops, that won't work! The requests just time out. And actually, it made perfect sense after just two seconds of thinking: nothing was telling anyone to route subdomains of server.local to that box, only server.local was advertised. I did a bit of digging, and found other people had run into the same issues, of course! Andrew Dupont's blog post, "Using mDNS aliases within your home network" quickly came up, and lucky for me, Andrew had already hammered out a good solution to this problem. The main reason I wrote this blog post was just to link to Andrew's, it's a great post, explaining the issues with mDNS, why subdomains aren't really a thing for mDNS, and the minor caveats one would need to consider when playing around with this stuff (mainly for Windows users).

For a variety of reasons (you should read the blog post), Andrew lands on using the Python package mdns-publisher, which is great, and easily scripted around.

With very minor modifications (swapping out usernames) I could have used Andrew's solution directly. But, whereas Andrew opted to use a new configuration file to list the additional hostnames, I already have them necessarily listed in my Caddyfile. Aside from not wanting to have to edit two files anytime I make a change to these services, I was pretty sure I'd just forget to do it and have to scratch my head for a second before remembering what the whole process was. So, I wrote my own Python script based on Andrew's to read the the hostnames from the Caddyfile instead of an additional file. It also uses pathlib and has some logging for convenience and easy debugging should something go wrong in the future.

#! /usr/bin/env python

from pathlib import Path
import re
import os
import logging

logger = logging.getLogger("publish-mdns-cnames")
logging.basicConfig(
    level="INFO"
)

caddyfile = Path("/etc") / "caddy" / "Caddyfile"

mdns_pattern = re.compile(r".*\.server\.local")

cnames = [
    # strip leading https?://
    re.sub(r"https?://", "", cname)
    for cname in mdns_pattern.findall(caddyfile.read_text(), re.I | re.M)
]

logger.info(" Extract CNAMEs from Caddyfile: %s", cnames)

logger.info(" Handing off to mdns-publish-cname")

os.execv("/usr/local/bin/mdns-publish-cname", ["mdns-publish-cname", *cnames])

I installed mdns-publisher globally using pipx, like so (thanks to this GitHub issue for the solution)

sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin \
    pipx install \
    mdns-publisher \
    --include-deps

Finally, I made a few changes to the systemd unit file that Andrew shares, to simplify things and make explicit the dependecy on Caddy:

[Unit]
Description=Avahi/mDNS CNAME publisher
After=network.target avahi-daemon.service caddy.service

[Service]
Type=simple
ExecStart=/usr/local/bin/publish-mdns-cnames
Restart=no
PrivateTmp=true
PrivateDevices=true

[Install]
WantedBy=multi-user.target

Tada! That gets it working just as I like. Now service.server.local resolves perfectly, and I never have to remember ports again :-P