Repository navigation
add a control channel to flush names from the cache, so health probes reach the upstream - #3
Conversation
flush/flush_type over control_listen_address (unix socket 0600 or TCP), also as -flush <name>[,<type>], so a health probe reaches the upstream.
How to flush a name from the cacheEnable the control channel in control_socket = '/run/dnscrypt/control.sock'From the command line, against the proxy running with that configuration file: dnscrypt-proxy -config dnscrypt-proxy.toml -flush example.com # all common record types
dnscrypt-proxy -config dnscrypt-proxy.toml -flush example.com,AAAA # one record typeThe reply is printed. The exit code is Over the socket directly, one line-based command per connection and one line of reply: printf 'flush example.com\n' | nc -U -q1 /run/dnscrypt/control.sock # -> ok 2
printf 'flush_type example.com AAAA\n' | nc -U -q1 /run/dnscrypt/control.sock # -> ok 1
printf 'purge example.com\n' | nc -U -q1 /run/dnscrypt/control.sock # -> error unknown command purgeNotes:
Updated for bf26622: unix socket only, |
MelvinTo
left a comment
There was a problem hiding this comment.
Summary
This PR adds an optional, unauthenticated control channel that removes names from the cache, so health probes go to the upstream. Some parts are done right: the channel is off by default, the Unix socket is created with mode 0600, the 2s idle timeout works, and cache key, case, and DNSSEC/CD handling match the cache plugin. Verdict: please fix the issues below before merging. The line length is not actually limited and the number of connections is not capped. A request already in flight when the flush runs can write the stale answer back into the cache. The TCP listener can also bind to non-loopback addresses. Each finding below was reproduced in isolated tests against this head, with the race detector on.
Findings
-
High: no real line-length limit and no cap on connections (resource exhaustion),
dnscrypt-proxy/control.go:93-98(see also line 59).bufio.NewReaderSize(..., 512)only sets the buffer size.ReadStringkeeps growing the line. A single ~4 MiB line was still accepted and returnedok 1, with ~12.7 MiB of extra allocation for one request. WithmaxClients=1, 64 idle control connections could still be open at once, each with its own handler goroutine. The 2s timeout does not limit how many bytes arrive in that window or how many connections exist. Suggested fix: enforce a hard line limit (e.g. wrap the conn inio.LimitReaderor usebufio.Scannerwith a max token size) and cap concurrent control connections. -
Medium: an in-flight request can re-cache a stale answer after a flush, so the health probe still hits the cache,
dnscrypt-proxy/control.go:184-189. Reproduced with the real cache plugin and a failing mock upstream: an earlier request gets the stale fallback, the flush returnsok 1and removes the entry, then the earlier request fails upstream and the response plugins write that fallback back as fresh. The next probe getscacheHit=truefor about 60s. This is a logic race, so the race detector does not catch it. Suggested fix: stop requests that started before the flush from re-populating that name (e.g. a per-name generation or epoch check), or give health probes an explicit cache-bypass path. -
Medium: TCP is not limited to loopback, so the unauthenticated endpoint can bind to every interface,
dnscrypt-proxy/control.go:65-67.listenControl("0.0.0.0:0")opens a wildcard listener and accepts unauthenticated flush commands. If the setting is a wildcard or non-loopback address and the port is reachable, outside clients can flush any name and use finding 1. The comment says to use loopback, but the code does not enforce it. Suggested fix: reject any TCP address that does not resolve to loopback. -
Low: a live Unix socket is deleted as if it were stale,
dnscrypt-proxy/control.go:71-79. The code only checks that the path is a socket, not whether another listener is still using it. In a repro, a second listen on the same path succeeded while the first listener was still running, and new connections went to the second one. Overlapping restarts or two instances with the same path can take over the control endpoint. Suggested fix: try to dial the existing socket first and only remove it if the connect fails (ECONNREFUSED). Otherwise return an error.
Unix socket only (control_socket), bounded line length and connections, never take over a live socket, and keep in-flight requests from re-caching a flushed name.
|
@MelvinTo thanks for the review. All four findings are addressed in bf26622. Each fix has a test in 1. No line-length limit / no connection cap. Fixed. The line is now read through 2. An in-flight request re-caching a flushed name. Fixed, using the epoch approach you suggested. Every flush records the flush time of each key it touches; entries are kept for 1 minute. I did not change the stale fallback so that it is never re-cached. That would change serve-stale for every name: during an outage each query would wait for the upstream timeout before getting its stale answer.
One addition to your analysis: the re-cached stale answer gets its TTL raised by 3. TCP not limited to loopback. Fixed by removing TCP altogether. The setting is now 4. A live unix socket deleted as stale. Fixed. An existing socket is dialed first. If it connects, startup fails with I updated the usage comment above for the new setting name. |
MelvinTo
left a comment
There was a problem hiding this comment.
Summary
This PR adds a cache-flush control socket so health checks can reach the upstream resolver. The hardening commit bf26622 fixes three of the four earlier findings (the resource-limit High, the TCP-exposure Medium, and the live-socket replacement Low). The in-flight cache-rewrite Medium is only partly fixed: a flush can still be undone by a response that passed the fence check just before it. Please fix that before merging. (Reviewed via GitHub reads; tests were inspected but not rerun.)
Findings
Medium: the flush fence check and cache insertion are not atomic
Previous finding #2 is still partly unresolved.
writtenBeforeFlush() releases its mutex before the response is inserted, and the cache operations use a separate mutex, so this interleaving is still possible:
- An older response passes the fence check at line 155.
- The control handler records the fence, removes the entry, and returns
ok. - The older response resumes and inserts its answer at line 168.
If that response carries the stale fallback, the next health probe gets a freshly cached answer despite the successful flush, which masks the upstream outage.
TestFlushFenceStopsInFlightRewrite finishes the flush before entering the response plugin, so it doesn't cover this window. Suggest doing the fence check and the insertion under the same synchronization as cache removal, and adding a test where a writer is paused between the check and the insertion.
Check and insert under a read lock, record and remove under the write lock, so a flush cannot fall between them.
|
@MelvinTo you're right. The fence check released its own lock before the insert, so a flush could land between the two. This is fixed in e0cdc8b. Fix. The fence check and the cache insert now share one
A flush therefore either runs before a store, and the store sees the fence and skips, or after it, and removes what was stored. It can no longer fall between the check and the insert. The previous Test.
With the check reverted to unlock before the insert, the test fails with |
MelvinTo
left a comment
There was a problem hiding this comment.
Summary
The shared read/write lock fixes the previously reported gap between checking the flush fence and inserting a response. But the fence timestamp is now captured before the flush takes its lock, so a request that arrives while the flush is waiting can read stale data and cache it again afterwards. That leaves one more Medium flush race to fix.
Findings
Medium: A pre-lock timestamp lets waiting-period requests undo the flush
dnscrypt-proxy/control.go#L225, dnscrypt-proxy/control.go#L265-L279
The remaining interleaving:
- An existing writer holds the guard's read lock.
- A flush captures timestamp T0, then waits for the write lock.
- Another DNS request starts at T1 > T0 and reads the old stale entry (cache reads don't take the guard).
- The flush gets its lock, records T0, removes the entry, and returns ok.
- The newer request's upstream fails. Its insertion passes because
requestStart.Before(T0)is false, restoring the stale answer as fresh.
The new regression test covers an older writer, but not a request that reads stale data while the flush is waiting.
Fix: remove the entries under the write lock, then sample and record the fence timestamp before releasing that lock. Add a regression test that gets a stale fallback during the flush's lock wait and checks that its later insertion is rejected.
Reviewed at e0cdc8b6b3d7c676895d4e489546c9c54d01e6dc (GitHub reads only; tests not run).
A request that started while the flush waited for the lock could read the stale entry and cache it again.
|
@MelvinTo agreed. The fence time was taken before the flush acquired the write lock, so a request that started during the wait could read the stale entry and later get past the fence. This is fixed in 6094ced. Fix.
Any request that could still have read a removed entry read it before the removal. That means its Test.
With the fence time moved back before the lock, the test fails with The one remaining bound is |
No description provided.