Skip to content

serve-expired-reply-ttl incorrectly applied to first-ever (cache-miss) query TTL, independent of response-mode #2416

Description

@realsamanrad

Problem Phenomenon

On the very first query for any domain (true cache miss, never queried before), SmartDNS replies to the client with a TTL equal to serve-expired-reply-ttl, instead of the real upstream-derived TTL (respecting rr-ttl-min). The internal cache entry is written correctly with the real TTL at the same moment, and every subsequent query for the same domain correctly returns the proper TTL from cache.

This happens regardless of response-mode:

  • With the default response mode, SmartDNS answers quickly (as soon as the first valid upstream response arrives) — TTL on that first reply is serve-expired-reply-ttl.
  • With response-mode fastest-ip (which waits for the full speed-test race across all upstream servers to complete before replying), the first reply still takes longer to arrive, but still returns TTL = serve-expired-reply-ttl.

Changing serve-expired-reply-ttl directly changes the first-query TTL (tested with 30 and 3 — the first-query TTL matched exactly in both cases), confirming the two are directly linked, even though no cache entry had ever existed/expired for these domains.

Per the documentation, serve-expired-reply-ttl is described as the "reply TTL value to use when replying with expired data" — i.e. it should only apply when re-serving a stale/expired cache entry while a background refresh happens. It should not apply to a domain's first-ever resolution, where there is no prior cache entry to have expired.

Operating environment

  • Firmware model: x86_64, Docker (Debian host)
  • Operator/ISP: N/A (self-hosted resolver)
  • SmartDNS source and version: pymumu/smartdns:latest (Docker Hub image)

Configuration details involved

bind [::]:53
plugin smartdns_ui.so
smartdns-ui.ip http://[::]:6080

cache-persist yes
cache-file /var/lib/smartdns/cache.db
data-dir /var/lib/smartdns

blacklist-ip 10.10.34.0/24

serve-expired yes
serve-expired-ttl 86400
serve-expired-reply-ttl 30
prefetch-domain yes
rr-ttl-min 3600

server 8.8.8.8 -blacklist-ip
server 4.2.2.4 -blacklist-ip
server 1.1.1.1 -blacklist-ip
server 9.9.9.9 -blacklist-ip
server 45.9.252.80 -blacklist-ip
server 45.9.253.53 -blacklist-ip
server 217.218.127.127 -blacklist-ip
server 217.218.155.155 -blacklist-ip
server 178.22.122.100 -blacklist-ip
server 185.51.200.2 -blacklist-ip
server 185.98.113.113 -blacklist-ip
server 185.98.114.114 -blacklist-ip

server-https https://1.12.12.12/dns-query
server-https https://1.1.1.1/dns-query
server-https https://9.9.9.9/dns-query
server-https https://8.8.8.8/dns-query -host-name dns.google
server-https https://185.95.218.43/dns-query
server-https https://116.202.176.26/dns-query
server-https https://45.8.222.100/dns-query
server-https https://194.242.2.2/dns-query
server-https https://223.5.5.5/dns-query
server-https https://146.112.41.2/dns-query

Reproduce steps

  1. Start SmartDNS (Docker, pymumu/smartdns:latest) with the config above, serve-expired-reply-ttl 30 and rr-ttl-min 3600.
  2. Pick a domain that has never been queried through this SmartDNS instance before (verified via fresh domain names not previously resolved, e.g. first.com, medium.com).
  3. Run dig @<smartdns-ip> <domain> — this is a genuine cache miss.
  4. Observe the returned TTL in the ANSWER SECTION: it equals serve-expired-reply-ttl (30), not a value derived from upstream TTL / rr-ttl-min.
  5. Run the same dig command again immediately after — the TTL is now correct (≈3600, matching rr-ttl-min), because this second query hits the now-populated cache.
  6. Change serve-expired-reply-ttl to 3 and restart. Repeat steps 2–4 with a new, never-queried domain — the first-query TTL is now 3 instead of 30, confirming the direct link.
  7. Set response-mode fastest-ip (forces SmartDNS to wait for the full upstream speed-test race before replying, instead of answering as soon as the first valid response arrives) and repeat with another new domain. The first reply now takes noticeably longer (waiting for all upstream servers to respond and be speed-tested), but the TTL returned is still equal to serve-expired-reply-ttl — ruling out "still racing / early reply" as the cause.

Upstream DNS configuration

12 UDP servers + 10 DoH servers, see full config above. Confirmed via debug log that multiple upstream servers (185.98.114.114, 9.9.9.9, 4.2.2.4, 217.218.127.127, 8.8.8.8, 217.218.155.155, 1.1.1.1, 185.98.113.113, 45.9.252.80) return a spoofed/hijacked IP (10.10.34.34) for test domains, correctly filtered out by -blacklist-ip/blacklist-ip 10.10.34.0/24 — this filtering works as expected and is unrelated to the TTL bug, included for context since it appears in the attached logs.

The domain names accessed

example.org, first.com, medium.com — all confirmed never queried through this SmartDNS instance prior to testing.

Evidence: dig output

First query (cache miss) — TTL 30, should reflect rr-ttl-min (3600):

root@mamia:~# dig @10.10.10.53 first.com

;; ANSWER SECTION:
first.com.              30      IN      A       104.26.4.251

;; Query time: 205 msec
;; WHEN: Sun Jul 12 14:12:03 +0330 2026

Second query (cache hit) — correct TTL:

example.org.            3598    IN      A       188.114.99.0
example.org.            3598    IN      A       188.114.98.0

Evidence: debug log (first.com)

[2026-07-12 10:42:03,644][DEBUG][         answer.c:187 ] domain: first.com TTL: 300 IP: 172.67.72.38
[2026-07-12 10:42:03,719][ INFO][        request.c:85  ] result: first.com, qtype: 1, rtt: 74.2 ms, 104.26.4.251
[2026-07-12 10:42:03,719][DEBUG][        context.c:846 ] reply first.com qtype: 1, rcode: 0, reply: 1
[2026-07-12 10:42:03,719][DEBUG][          cache.c:143 ] cache first.com qtype: 1 ttl: 3600
[2026-07-12 10:42:03,719][ INFO][        context.c:901 ] result: first.com, client: 10.10.10.7, qtype: 1, id: 20019, group: default, time: 205ms
...
[2026-07-12 10:42:04,034][DEBUG][          query.c:50  ] result: first.com, qtype: 1, has-result: 1, id 9491
[2026-07-12 10:42:04,034][DEBUG][        context.c:846 ] reply first.com qtype: 1, rcode: 0, reply: 0
[2026-07-12 10:42:04,034][DEBUG][          cache.c:143 ] cache first.com qtype: 1 ttl: 3600

Note: the log shows the internal cache is written with ttl: 3600 (correct) at 10:42:03,719 — the exact same moment the client-facing reply (reply: 1) is sent with wire TTL 30. The cache value is correct; only the TTL field in the packet sent to the client is wrong on this first reply.

A second, very similar capture for medium.com (10 upstream servers responding, no blacklist hits involved at all) shows the identical pattern: client-facing TTL 30 on the first reply (reply: 1 at 59,260), internal cache written as ttl: 3600 at the same timestamp, and a suppressed second reply (reply: 0) once the rest of the upstream race completes at 59,734.

Expected behavior

The TTL returned to the client on a genuine first-time cache-miss resolution should reflect the actual resolved TTL (subject to rr-ttl-min/rr-ttl-max), not serve-expired-reply-ttl — since no cache entry existed to have expired.

Actual behavior

The first-ever reply for any domain uses serve-expired-reply-ttl as its TTL, regardless of response-mode or how quickly/slowly the reply was generated. Only the second and later queries (true cache hits) return the correct TTL.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions