Skip to content

config: add dns.hostsLocal to keep custom DNS records local - #3037

Open
DL6ER wants to merge 3 commits into
developmentfrom
new/hosts_local
Open

config: add dns.hostsLocal to keep custom DNS records local#3037
DL6ER wants to merge 3 commits into
developmentfrom
new/hosts_local

Conversation

@DL6ER

@DL6ER DL6ER commented Aug 21, 2026

Copy link
Copy Markdown
Member

What does this implement/fix?

A custom DNS record only ever defines an address. Any other record type for that name - AAAA when you configured an A, or the HTTPS query browsers send - has no local answer, so dnsmasq forwards it upstream. When the name also exists publicly, the public answer comes back and getaddrinfo() prefers the AAAA over our local A. The override silently stops overriding, and only for some clients: dig sends A alone and looks perfectly fine, which is what makes this so hard to diagnose.

We now write one local=/<name>/ line per hostname in dns.hosts, telling dnsmasq the name is ours and that nothing about it should leave the box. The scope is the configured names and their subdomains, so split DNS keeps working - with sub1.example.com and sub2.example.com defined, sub3.example.com and example.com itself still resolve from upstream as before.

The new dns.hostsLocal is on by default. What it changes is a name you have explicitly claimed, and the old behavior is one setting away, whereas the failure it prevents is silent and client-dependent. Set dns.hostsLocal = false to get it back.

How to test the change during review

Point a custom DNS record at a name that also exists publicly with an AAAA record, e.g., dns.hosts = [ "10.0.0.1 sub1.example.com" ], then from a client:

dig @<pi-hole> sub1.example.com A       # local address, as before
dig @<pi-hole> sub1.example.com AAAA    # NODATA, was the public address
getent ahosts sub1.example.com          # local address, was the public one
dig @<pi-hole> sub3.example.com A       # still resolved upstream

grep '^local=' /etc/pihole/dnsmasq.conf shows one line per configured hostname, and pihole-FTL --config dns.hostsLocal false restores the previous behavior.

Automated coverage: test/test_suite.bats asserts the generated local= lines ("Custom DNS records are treated as local"), and test/pihole.toml carries the new key so the template comparison and the config-write accounting in test_final.bats stay exact.

Additional information

Related issue or feature (if applicable): #2841

Pull request in docs with documentation (if applicable): N/A


By submitting this pull request, I confirm the following:

  1. I have read and understood the contributors guide, as well as this entire template. I understand which branch to base my commits and Pull Requests against.
  2. I have commented my proposed changes within the code.
  3. I am willing to help maintain this change if there are issues with it later.
  4. It is compatible with the EUPL 1.2 license
  5. I have squashed any insignificant commits. (git rebase)
  6. My change does not modify src/dnsmasq/. That tree is a verbatim copy of upstream dnsmasq and we do not carry anything in it that deviates from upstream. Fixes have to go through the dnsmasq-discuss mailing list first, we merge them once they are in dnsmasq master.

Checklist:

  • The code change is tested and works locally.
  • I based my code and PRs against the repository's development branch.
  • I signed off all commits. Pi-hole enforces the DCO for all contributions
  • I signed all my commits. Pi-hole requires signatures to verify authorship
  • I have read the above and my PR is ready for review.

@yubiuser

Copy link
Copy Markdown
Member

I think this is a sensible change. Do you think we should expose this setting via UI at the 'Local DNS records' page?

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

1 similar comment
@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Since dnsmasq 2.86, a query for a record type that has no local answer is forwarded upstream even when the name itself is defined locally. For a custom DNS record under a public domain that means the `AAAA` query `getaddrinfo()` sends alongside the `A` query comes back with the *public* address, which glibc then prefers - so the override silently does not override. `dig` only ever sends `A` and looks correct, which is what makes this so hard to spot.

We now emit one `local=/<name>/` line per hostname in `dns.hosts`, which tells dnsmasq that the name is ours and stops the forwarding. This is scoped to the configured names and their subdomains, so everything else in the same zone keeps resolving from upstream as before. Set `dns.hostsLocal = false` for the previous behavior.

Signed-off-by: DL6ER <dl6er@dl6er.de>
@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

1 similar comment
@github-actions

Copy link
Copy Markdown

Conflicts have been resolved.

@DL6ER
DL6ER marked this pull request as ready for review August 23, 2026 04:15
@DL6ER
DL6ER requested a review from a team as a code owner August 23, 2026 04:15
Copilot AI lite review requested due to automatic review settings August 23, 2026 04:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the default-enabled dns.hostsLocal option to keep configured custom DNS hostnames and subdomains local.

Changes:

  • Generates dnsmasq local= directives.
  • Adds configuration, API documentation, and test fixture support.
  • Adds tests for generated directives.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Summary
test/test_suite.bats Tests generated local rules; coverage should include enabled/disabled behavior and runtime updates.
test/pihole.toml Adds the new test configuration entry.
src/config/dnsmasq_config.c Generates local DNS rules; host mutations must regenerate or reload these rules.
src/config/config.h Adds the configuration field.
src/config/config.c Registers the new setting and default.
src/api/docs/content/specs/config.yaml Documents the API property.
Suppressed comments (2)

src/config/dnsmasq_config.c:838

  • When dns.expandHosts is enabled with a non-empty dns.domain.name, dnsmasq creates a second hosts entry such as foo.lan for a dotless foo (src/dnsmasq/cache.c:1513-1525). local=/foo/ does not match foo.lan, so queries such as AAAA or HTTPS for the expanded name can still be forwarded, leaving the override vulnerable on the exact path this setting is meant to close. Emit a local rule for each expanded name as well, taking the effective expansion suffix into account.
				fprintf(pihole_conf, "local=/%s/\n", name);

src/config/dnsmasq_config.c:838

  • The dns.hosts validator permits a final label longer than 63 characters because it only checks label length when it encounters a dot, while dnsmasq's canonicalise() rejects labels over MAXLABEL (63). Thus an otherwise accepted entry such as 1.2.3.4 <64-character-label> now emits an invalid local= directive, causing generated-config validation or a later restart to fail. Validate/canonicalize the hostname before emitting it, or fix the host validation path.
				fprintf(pihole_conf, "local=/%s/\n", name);

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

// for a type we have no local record for (e.g., AAAA when only an A
// address is defined) is forwarded and a public answer for the same
// name shadows the local address
if(conf->dns.hostsLocal.v.b && cJSON_GetArraySize(conf->dns.hosts.v.json) > 0)
Comment thread test/test_suite.bats
Comment on lines +704 to +710
@test "Custom DNS records are treated as local (dns.hostsLocal)" {
# One local= per hostname configured in dns.hosts, so a type we have no
# local record for is not forwarded and answered upstream instead
run bash -c 'grep -c "^local=/abc-custom.com/$" /etc/pihole/dnsmasq.conf'
assert_line --index 0 "1"
run bash -c 'grep -c "^local=/def-custom.de/$" /etc/pihole/dnsmasq.conf'
assert_line --index 0 "1"
DL6ER added 2 commits August 23, 2026 10:48
The `local=` lines come from `write_dnsmasq_config()`, but `dns.hosts` is not a dnsmasq option, so a record added through the API or the CLI kept being forwarded until an unrelated restart, and a deleted one kept its rule. Both API paths, the CLI and the `pihole.toml` re-read rewrite the config now. dnsmasq reads `local=` at startup only, so this restarts the resolver; `dns.hostsLocal = false` keeps the old behavior.

While here: the CLI wrote `custom.list` from the old records, before the new config was installed. The changed test counters follow the two new queries and that restart, which rebuilds the domain table from the database.

Signed-off-by: DL6ER <dl6er@dl6er.de>
`valid_domain()` measured a label when it found the terminating dot, so the last one was never checked and `1.2.3.4 test.<64 characters>` passed. dnsmasq rejects labels above 63 characters, so with `dns.hostsLocal` such an entry produced a `local=` line that makes the resolver refuse to start.

Signed-off-by: DL6ER <dl6er@dl6er.de>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants