Guide

Groups, devices, and VLANs

A group decides which lists, upstream servers, and answer settings apply to a set of devices. Give each network its own group, name the devices on it, and every chart and log line shows who asked.

For what groups show on the dashboard and the Groups page, see Groups and VLANs.

How a device finds its group

Each query's device is recognized first, then given its groups. The first match wins:

  1. Client ID (id:…), from the DoH URL path /dns-query/<id> or the DoT/DoH server name <id>.dns.example.com.
  2. MAC address, from a trusted router's EDNS option or from the kernel's neighbor table (ARP and IPv6 NDP, read every 60 s).
  3. Exact IP address.
  4. The most specific CIDR.
  5. default, for every device that matched nothing.
  • By network: a device on a group's network belongs to that group, with no per-device setup. The most specific network wins.
  • By device: a [[client]] entry with its own groups wins over the network. Naming a device without giving it groups keeps it in its network's group.
  • In several groups, a device gets every list of all of them. Other settings, such as the blocked answer, come from its highest-priority group.

MAC addresses survive DHCP changes and IPv6 privacy addresses, so they're the most dependable key at home. The neighbor table only sees real devices with host networking (as on a Pi). In a container's bridge network, every query appears to come from the gateway.

A group per VLAN

Give each VLAN a group that owns its network. Filtering doesn't change until you give a group lists: a group without them uses every list, like default.

Web UI

  1. Open Groups → Manage groups, and add a group (or Edit one).
  2. Under Networks, enter its networks, one per line (192.168.20.0/24).
  3. Tick its Lists, and pick its Upstream servers. (default) means the default upstream group.
  4. In the Advanced view, set its Priority and Color if you like. Check shows what the change does; nothing is saved until Apply.

Config file

Make the upstream groups first, from presets or by hand:

telltale presets show cloudflare-family --proto tls --group family >> telltale.toml
telltale presets show quad9 --proto tls,https --group privacy >> telltale.toml

Then one group per VLAN (shortened from the docs' example):

[[group]]
name = "Trust"                    # laptops and phones
networks = ["192.168.10.0/24"]
lists = ["hagezi-pro"]
upstreams = "privacy"

[[group]]
name = "Kids"
networks = ["192.168.20.0/24"]
lists = ["hagezi-pro", "family-extra"]
upstreams = "family"
priority = 10

[[group]]
name = "IoT"                      # TVs, plugs, cameras
networks = ["192.168.30.0/24"]
lists = ["hagezi-pro", "smart-tv"]
rebinding_protection = true

[[group]]
name = "Guest"
networks = ["192.168.40.0/24"]
lists = ["hagezi-light"]
block_mode = "nxdomain"

IoT has no upstreams, so it uses default. Apply it with a reload (SIGHUP).

API

PUT or DELETE on /api/v1/groups/{name}, with ?dryRun=true to preview. The body has the same fields as the [[group]] section (networks, lists, upstreams, and so on).

  • A network belongs to one group: the same CIDR in two groups is a configuration error. Nested networks are fine.
  • Which upstream answers, most specific first: a [[route]] for a domain (such as home.arpa to your router, for every group), a [[route]] with match_group, the group's upstreams, then default.
  • Caching is per upstream group, so a family resolver's answers are never served to another group.
  • Blocked services, safe search, and schedules are group settings too: see Block, allow, and explain.
  • See the effect: filter the query log by Group, or look at the group's card on the Groups page. In a cluster, every node attributes devices the same way.

Details: Groups for your networks and Upstream servers per group in the docs.

Name your devices

A name shows everywhere at once, including on past queries: names are looked up when data is read, so renaming relabels history.

Web UI

  1. Click a device's address anywhere: the dashboard's top clients, the query log, the live view, or Clients.
  2. Choose Name this device…, or Add to group… to put it in a group.

Operators and admins can name devices; viewers see the names. A device defined in a config file is read-only here.

Config file

[[client]]
name = "Kids tablet"
match = ["aa:bb:cc:dd:ee:01", "192.168.1.50", "id:kids-tablet"]
groups = ["kids"]

[[client]]
name = "Office"
match = ["10.0.5.0/24"]       # no groups: its network's group, else "default"

File entries win over names given in the UI, and the UI can't reuse their names. Changes apply on reload (SIGHUP).

API

curl -X PUT 'https://dns.example.com/api/v1/clients/Living%20room%20TV?dryRun=true' \
  -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
  -d '{"match": ["192.168.1.42"], "groups": ["default"]}'
  • Without dryRun the change is saved, applied, and audited.
  • Send GET /api/v1/clients's ETag back as If-Match to refuse the write if the config changed meanwhile (412). An Idempotency-Key header makes retries safe.
  • Send a new name in the body to rename; DELETE /api/v1/clients/{name} forgets a device.

Names from your router and mDNS

Your router's DHCP hands out addresses; TelltaleDNS can read its client list, so devices show up by name without any typing.

[[router]]
name = "udm"
type = "unifi"                         # UniFi OS consoles and the Network application
url = "https://192.168.1.1"
api_key_file = "/etc/telltale/unifi-key"   # UniFi Network 9+ (Settings → Control Plane → Integrations)
tls_ca = "/etc/telltale/udm.pem"       # the console's own certificate (self-signed)
tls_server_name = "unifi.local"        # the name in it

[[router]]
name = "firewall"
type = "opnsense"
url = "https://opnsense.lan"
api_key_file = "/etc/telltale/opn-key"     # System → Access → Users → API keys
api_secret_file = "/etc/telltale/opn-secret"
  • UniFi gives the connected clients, named by their UniFi alias, else their host name. OPNsense gives its DHCP leases (Dnsmasq, Kea, or ISC).
  • A self-signed UniFi console: save its certificate as tls_ca (openssl s_client -connect 192.168.1.1:443 </dev/null | openssl x509 > udm.pem). It names unifi.local, not the address you reach it at, so set tls_server_name to that name. The console is then verified, and only that certificate is trusted.
  • Credentials stay in files. Routers are read every interval_secs (300). Changes to [[router]] need a restart.

mDNS: phones, speakers, printers, and NAS boxes announce names like Kitchen-HomePod.local. To use them for devices you haven't named:

[clients]
mdns = true
  • Listen only: TelltaleDNS never sends mDNS, and it shares port 5353 with avahi. It needs to hear the LAN: a native install, the Pi bundle, or a pod with host networking. Changing it needs a restart.
  • Which name wins: the name you gave, then the router's, then mDNS.
  • In a cluster, one node reading the router (or hearing mDNS) is enough: every node uses the names the others know, so resolver pods name devices the same way the primary does.
  • Clients → Discovered lists every device a router or mDNS found; naming one from there suggests the name it gave. GET /api/v1/dhcp/leases lists them too.

Details: Names from your router and Names devices announce.

What is this device?

TelltaleDNS guesses what each address is from what it already sees: the maker of its network card (from the MAC address), the name it announces, and the domains it talks to. The Clients page then says, under the device: "Looks like a Roku player (likely) · Roku". Why? lists the evidence.

  • Naming: Name this device… fills in the guess when the device hasn't told your router a name. A guess never becomes a name by itself.
  • Suggested groups: give a group the kinds of device that belong in it, and matching devices get it suggested. Nothing moves on its own.
[[group]]
name = "iot"
device_classes = ["camera", "doorbell", "plug", "bulb", "hub", "thermostat", "vacuum"]
  • Wrong guess? Name the device and set What it is ([[client]] kind = "camera", or "unknown" to stop guessing).
  • It never changes how a query is answered. It's off at query-log privacy level 1 and above, and with [identify] enabled = false. How it works.

Names on your network

Local names are answered by TelltaleDNS itself, before the cache and the upstreams.

Web UI

  1. Open Names on my network. It lists every local name, grouped by domain.
  2. For a first device, run Set up my home domain (home.arpa by default). Add, edit, or remove names: A, AAAA, and CNAME in Simple, every type in Advanced.
  3. To send a domain to your router or another server, use Send a domain to another server.

Each change is previewed in a sentence and a diagram before it's saved, and every name has a Test link to Explain. Names from the config files are read-only.

Config file

[[record]]
name = "nas.home.arpa"
type = "A"                      # A, AAAA, CNAME, PTR, TXT, MX, SRV
value = "192.168.1.10"

[local]
hosts_files = ["/etc/telltale/hosts"]   # "IP name [alias...]" lines
auto_ptr = true                          # reverse lookups for every A/AAAA (default)

A [[zone]] makes TelltaleDNS the authority for a whole domain: names it doesn't have get NXDOMAIN. With groups, only those groups see it (split horizon):

[[zone]]
name = "corp.example.com"
groups = ["office"]
[[zone.record]]
name = "intranet.corp.example.com"
type = "A"
value = "10.10.0.5"

API

  • GET /api/v1/records, and PUT/DELETE /api/v1/records/{name} with {"records": [{"type": "A", "value": "192.168.1.10"}]}.
  • GET /api/v1/forwards, and PUT/DELETE /api/v1/forwards/{domain} with {"servers": ["10.0.0.53"]}.
  • All take ?dryRun=true, If-Match, and Idempotency-Key, like devices.

Details: Local records and Local zones.

Per-group answers

These are set on a group. In the UI, the last three are in the group's editor, in the Advanced view.

SettingWhat it does
[[group.rewrite]]Answers a name differently for the group (an address, or a CNAME to another name), such as a split-horizon name or a test server. Blocks still win; for a name everyone should get, use a local record.
dns64 = trueFor IPv6-only devices behind NAT64: names without an IPv6 address get one made from their IPv4 address (prefix 64:ff9b::/96 by default).
filter_aaaa = true (No IPv6 addresses)For a network whose IPv6 is broken: AAAA questions get an empty answer, so devices use IPv4 at once. Your local records still answer AAAA. Not with dns64.
rebinding_protection = trueBlocks public names that answer with private, loopback, link-local, or CGNAT addresses (DNS rebinding). rebinding_allow lists names that may (plex.direct); block_answer_ips refuses answers in networks you list, for any name. Local records and routed domains are never affected.
[[group]]
name = "guest"
networks = ["192.168.50.0/24"]
filter_aaaa = true

Real addresses, access, and rate limits

Per-device statistics, groups, and rules need each query's real sender. Route your VLANs to TelltaleDNS rather than through a forwarder that rewrites addresses. When most recent queries come from a few infrastructure addresses (loopback, gateways, node addresses), the UI shows a Client IPs appear masked banner.

CauseFix
Kubernetes with externalTrafficPolicy: ClusterUse Local (the chart's default) or hostNetwork: true.
Docker with a userland proxy (IPv6 or 127.0.0.1 port publishing)Run with network_mode: host, or publish on the LAN address.
A router that forwards DNS for its clientsHand out TelltaleDNS's address in DHCP instead. If you can't, enable the router's EDNS MAC option (dnsmasq add-mac) and trust it, as below.
[clients]
trust_edns_mac_from = ["192.168.1.1/32"]   # ignored by default: any device could send it
infrastructure = ["10.42.0.0/16"]          # e.g. your cluster's pod network

Who may query: by default, private ranges, CGNAT/Tailscale (100.64/10), link-local, and loopback; everyone else gets REFUSED. TelltaleDNS is never an open resolver by default.

[access]
allowed_networks = ["192.168.0.0/16", "fd00::/8", "127.0.0.0/8"]

[ratelimit]
queries = 1000          # per client per window
window_secs = 60
action = "refused"      # or "drop"
exempt = ["127.0.0.0/8", "::1/128"]
  • A router or proxy that forwards for a whole network looks like one address and is refused above about 17 queries a second: add it to exempt, or raise queries.
  • Change the rate limit in Settings → Query handling → Rate limit, or with PUT /api/v1/ratelimit/default. A change replaces the whole section, so keep 127.0.0.0/8 and ::1/128 in the exempt list. Details.

Troubleshooting

What you seeWhat to do
Every query comes from one address, or Client IPs appear maskedSomething rewrites the sender: see the causes above. With Docker's bridge network, every query appears to come from the gateway.
A device isn't in its VLAN's groupCheck that it reaches TelltaleDNS with its own address, and that no [[client]] entry gives it other groups (those win over the network).
The config is refused: a network in two groupsA CIDR can belong to one group only. Nested networks are fine.
You can't edit a device in the UIIt's defined in a config file; files win. Change it there.
Router or mDNS names don't appearRestart after changing [[router]] or mdns. mDNS needs host networking. For a self-signed UniFi console, set tls_ca and tls_server_name.
A phone shows no makerPhones use private Wi-Fi addresses, so the maker is unknown; the domains and name decide the guess.
Queries through your router are refusedIt's over the rate limit as one address: add it to [ratelimit] exempt or raise queries.
Rebinding protection blocks a service you useAdd its domain to the group's rebinding_allow.