A Dashboard for Technitium's Blocked Services

Part 3 of 3 in Replacing AdGuard Home with Technitium DNS.

In Part 1 I converted AdGuard's blocked services into plain text blocklists and served them from nginx. It worked, but I had quietly turned a feature into a chore. AdGuard's "block Snapchat" switch kept itself up to date; my text files would go stale the day Snapchat added a domain.

I missed the switches too. Changing what the kids' iPads can reach shouldn't mean editing a 748-line text file by hand.

So I built a small sidecar container that brings both back.

The Idea

AdGuard publishes its blocked-service definitions in a public Git repository (HostlistsRegistry, assets/services.json). Each service has an ID, a name, a category and a list of rules. That's exactly the data behind AdGuard's switches.

The sidecar:

  1. Pulls that file with a shallow git fetch of the official repo.
  2. Shows every service as a switch, separately for each profile (vlan0, vlan10, vlan20, vlan30, vlan40 and kids-ipads).
  3. Compiles the switches into one plain-text domain list per profile.
  4. Writes it into the folder my nginx container already serves.

Technitium still does the actual blocking. The dashboard only maintains the files that Advanced Blocking's blockListUrls already point at, so nothing in Technitium changed. It never talks to Technitium and holds no DNS credentials.

It's a small Python (FastAPI) app in one container. I built it in a separate AI-assisted session, and it has a test suite for the parts that could hurt me.

Using It

Press Sync from GitHub and the catalog loads: 142 services at the time of writing, grouped by category. Pick a profile, then search or filter and flip switches.

Dashboard for the kids-ipads profile with a search for "snap", showing Snapchat switched on and a NOT MANAGED warning Searching the catalog. The orange line says this profile's existing list hasn't been touched yet

I didn't want to re-tick 130 boxes by hand, so a seed script preloaded the switches from the audit I made during the AdGuard migration: 31 services for VLAN0, 31 for VLAN10, 38 for VLAN40 and 32 for the kids' iPads. It only sets switches; it never writes a list.

Preview generated list compiles the selection and shows what would be published without writing anything.

Preview panel showing 746 unique domains, 10 unsupported upstream rules and example rules that were skipped The preview: how many domains, and which upstream rules couldn't be converted

Plain Domains Only

AdGuard rules can be much fancier than a hosts list. Technitium's list format can't express wildcards, exceptions or $denyallow, so the compiler is deliberately conservative:

  • Plain domains and simple ||domain^ rules become entries.
  • Wildcards, IP addresses, regexes and modifiers are skipped and reported, never guessed at.
  • A service containing an exception (@@) or denyallow rule is left out entirely, because dropping only the exception could block more than AdGuard would have.

You can see the cost in the preview above: 10 rules were skipped for the kids' profile. That's the same gap I mentioned in Part 1, now visible instead of silent. The count is also two lower than the 748 domains I generated by hand in October, probably because AdGuard's catalog has changed since then.

Publishing Without Breaking Anything

Publishing replaces a live file that my whole network's DNS depends on, so most of the code is about being careful:

  • First launch changes nothing. A profile only becomes "managed" when I explicitly publish it. Until then its old file is left alone.
  • Backups and rollback. Each publish saves the previous file first, and a Restore button puts it back.
  • Atomic writes. Files are replaced in one step, so Technitium never downloads a half-written list. If a later step fails, earlier replacements are rolled back.
  • Confirmations. Publishing a partial list (skipped rules) or an empty one needs a separate approval. The approval is tied to that exact output, so it doesn't carry over if the catalog changes.
  • Safe sync. Selections survive catalog updates. A removed or renamed service is reported as missing and blocks publishing; it isn't silently dropped.

Rollback panel listing a timestamped backup with a Restore button, and a confirmation that 746 domains were published After publishing: a timestamped backup to restore, and a reminder to refresh Technitium

Once a profile is managed, the container re-syncs with GitHub every 24 hours and regenerates its list only if the rules actually changed.

The dashboard for a managed profile, with a LIVE warning and a grid of social network services, some switched on A managed profile is marked LIVE: publishing replaces its active list

Running It

It sits next to the nginx list server from Part 1, on the same Docker network. No host port is published:

services:
  service-control:
    build: .
    container_name: technitium-service-control
    restart: unless-stopped
    environment:
      APP_ORIGIN: ${APP_ORIGIN}
      SYNC_HOURS: 24
    volumes:
      - ${LISTS_HOST_DIR}:/lists   # the folder nginx already serves
      - ${STATE_HOST_DIR}:/data    # switches, catalog, backups
    networks:
      - proxy
    security_opt:
      - no-new-privileges:true
    read_only: true
    cap_drop:
      - ALL

The container runs as an unprivileged user with a read-only filesystem, so that user needs write access to the lists folder. Nginx Proxy Manager gives it its own HTTPS hostname.

Security

The dashboard has no login. I decided that's acceptable for a page that only edits text files, as long as it can't be reached from anywhere I don't control:

  • It has its own hostname, on a private VLAN with an Nginx Proxy Manager access list. It is not on the public blocklist hostname.
  • Changes are only accepted from the configured origin with a custom request header, which blocks casual cross-site requests.
  • It holds no Technitium credentials, and the worst it can do is publish a bad list, which the backups cover.

If you can't restrict access that way, put real authentication in front of it.

What It Doesn't Do

  • Changes aren't instant. The dashboard writes files; it doesn't tell Technitium to reload them. Advanced Blocking re-downloads on its own schedule (24 hours in my setup), so a change can take up to a day to apply unless you shorten the interval or refresh by hand.
  • It isn't full AdGuard parity. Skipped rules mean some services are slightly less blocked than they were in AdGuard.
  • Only the service lists. My ordinary ad-block lists, allowlists and WireGuard mappings stay in Advanced Blocking.
  • One instance only. There's no locking between multiple copies.

The screenshots come from a scratch copy running against throwaway files, not from my live lists.

Was It Worth It?

I think so. The thing I liked most about AdGuard was that switches kept themselves current and I never had to think about domains. Now that's true again, with the difference that I can see exactly what each switch compiles to before it goes live.

Part 1 had one real regression compared with AdGuard: I'd traded switches for hand-edited lists. This puts the switches back.

That is the end of the series. Previously: Making Technitium DNS Survive a Proxmox Host Failure