Repository navigation
Troubleshooting
| Problem | Symptom | Fix |
|---|---|---|
| No trusted CA | DoH server connection error: SSL: no trusted CA certificate found (6) |
import the CA, set trusted=yes
|
| Wrong CA | error persists | Cloudflare = SSL.com TLS ECC Root CA 2022
|
| Clock off | cert validation fails, monitor says "DoH down" | enable NTP (IP-based servers) |
device-mode=home |
not allowed by device-mode on fetch/scheduler |
run the monitor by hand; monitor cannot probe there |
| DoH down | resolution stops, servers do not help |
monitor switches to the fallback DoH |
| Sites do not open, DNS "fine" | CDNs time out, resolver healthy | stub pair — see below |
| Failover never triggers | log both endpoints unreachable
|
probe needs DNS: add bootstrap records |
| Symptom | Cause | What changed |
|---|---|---|
Primary endpoint is down, /ip dns still points at it, clients get nothing |
In probeMode "resolve" the fallback was still probed over DNS-JSON; a self-hosted endpoint answers 400, so probeB never turned ok and the switch was skipped |
In resolve mode the fallback is not probed — the monitor switches and verifies by :resolve afterwards, rolling back if the new endpoint answers badly |
| Substituted answers accepted on the fallback path | Only the primary probe looked at the payload | The fallback probe reports substituted as well |
Reserve endpoint on a separate host — see "A reserve endpoint on a separate host" below.
A failover between two public resolvers is weak: both sit behind the same path and get substituted together. A reserve that helps is your own resolver on a host that is not on the broken path.
Shape: your existing web server (443) → location = /dns-query with allow/deny → an
HTTP/1.1 DoH front end on loopback → unbound with forward-tls-upstream.
The pitfall. On Ubuntu 26.04 the packaged dnsdist is 2.x and its plain-HTTP DoH
listener is HTTP/2-only: an HTTP/1.1 request gets an HTTP/2 frame back (BadStatusLine,
000), and nginx cannot proxy to it at all — nginx has no HTTP/2 upstream for
proxy_pass. h2=false is rejected (Unknown key 'h2' given - ignored).
Options that work: dnsdist 1.7 (as on Debian 12), or a small gateway — http.server on
loopback that turns a POST body into a UDP query and returns the answer. No dependencies,
plain HTTP/1.1, easy to audit.
unbound cannot sit behind a plaintext proxy: http-endpoint needs TLS keys, and the
packaged service usually cannot read a Let's Encrypt key without a copy and a deploy hook.
Checks, in order: gateway alone (200), through nginx from the host (400 on GET is
correct — that is not a DoH request), from a client with the wire format (200 with honest
addresses; 403 means the address is missing from the allow list).
Three field problems turned into code and documentation in this release. Each row names the symptom you would have seen, the cause, and where the fix lives.
| Symptom | Cause | What changed |
|---|---|---|
Clients open nothing for some names while the resolver reports healthy, Status: 0
|
The network substitutes the DoH answer itself: same URL, same certificate, stub address. The probe only looked at the status field | Probe checks the payload for stubPrefixes and reports substituted (see "The network substitutes DoH answers as well") |
Own DoH endpoint shows as bad-status and the router jumps to a public fallback |
The probe speaks DNS-JSON; a self-hosted resolver serves wire format and rejects that request |
probeMode "resolve": liveness from live-answer quality, no external request |
| After pointing at an honest resolver, clients still get stubs for a while | The stubbed answer was already cached; it lives until TTL, so the monitor declared STUB on stale data |
One cache flush and a re-check before declaring; STUB_CLEARED when the cache was the culprit |
Importing the Zabbix modules fails with ZBX_USER is not set
|
The credential check ran at module level, so even import needed the environment |
Check moved into main(); the console scripts read ZBX_USER/ZBX_PASSWORD when they run |
| Collector exits with a Python traceback |
zabbix_sender was missing; subprocess raised FileNotFoundError
|
Clear message naming the binary and the package to install; path from ZBX_SENDER
|
How to verify each one on a live router: Verification → "0.4.2 checks".
Symptom. cdn.tailwindcss.com, cdnjs.cloudflare.com, unpkg.com time out.
The router is fine: DoH answers, Status:0, a valid A record.
What is returned. The pair 8.6.112.0 / 8.47.69.0. Measured
2026-10-26 from three vantage points:
| Query | Answer |
|---|---|
| DoH, standard endpoint, Russian ISP |
104.26.x at first, the pair ~30 minutes later |
| DoH, Family endpoint, same vantage | the pair |
| plain DNS to 1.1.1.1 and 8.8.8.8 | the pair (Google answered 8.6.112.5) |
| the same questions from Germany |
104.26.x, 172.66.x
|
curl --resolve example.com:443:8.6.112.0 from a gateway WAN |
200, valid certificate |
| the same from another ISP's branch | timeout |
The pair is served by Cloudflare and is a real answer — not a block, not a filter artefact. It is simply unreachable from some networks. Where the path is missing, every name resolving to it stops working, whatever endpoint you use.
Confirm from a client (the router cannot tell — /tool fetch cannot
distinguish a dead path from an HTTP refusal):
curl -sS -o /dev/null --max-time 10 \
--resolve cdn.tailwindcss.com:443:8.6.112.0 \
-w '%{http_code} %{time_total}s\n' https://cdn.tailwindcss.com/
# 302 in ~0.2 s -> reachable here, the pair is harmless
# timeout -> unreachable: this is your outageWhat fixes it: route the pair through a working tunnel; or pin reachable
addresses with /ip dns static for the names you need; or change the vantage
point. Switching resolvers does not help, and
/ip dns static type=FWD forward-to=1.1.1.1 does not either (the router forwards
over plain DNS and gets the same pair).
The probe asks the endpoint by hostname. If the resolver is dead, that hostname cannot be resolved, the probe fails and the monitor concludes "both endpoints unreachable — keeping the current configuration". Pin bootstrap records for both endpoint hosts:
/ip dns static print where comment~"DoH bootstrap"| Endpoint | Probe URL |
|---|---|
| Cloudflare (all three) | https://cloudflare-dns.com/dns-query?name=example.com&type=A |
https://dns.google/resolve?name=example.com&type=A |
|
| Quad9 | unsupported: DoH unable to decode BASE64-URL
|
Quad9 is fine as an upstream but cannot be probed this way — a Quad9 fallback would always look unreachable.
dstnat redirect catches clients that send DNS to other addresses. A client
whose DNS points at the router is answered locally and never enters dstnat, so
per-client resolver policies built on dst-nat do nothing for it. Check the client:
ipconfig /all → DNS Servers.
/ip dns static wins over the DoH answer — useful for split-horizon names and for
pinning a reachable address; dangerous when a leftover entry keeps a dead address.
/ip dns static print
/ip dns cache print where name~"tailwindcss"A resolver can look healthy, answer Status: 0, and still hand out addresses that open
nothing. The substitution is not always on plain DNS: measured on one network, the same
DoH URL answered honestly when reached through an encrypted tunnel and returned the stub
pair when reached from the router's own uplink. Same URL, same certificate, different
answers.
It is also unstable: repeated queries alternate between honest and stubbed answers, so a single successful probe proves nothing.
| Symptom | What it means |
|---|---|
Status: 0 but the address is the stub pair |
the path is intercepted; the answer is syntactically perfect |
| Alternating honest/stub answers for the same name | active substitution, not a cache problem |
| Public DoH fallback behaves exactly like the primary | both paths are intercepted; failover between operators cannot help |
Filtering resolver ("family", 1.1.1.3) always returns the pair |
it filters by design — never use it as a baseline |
Since 0.4.2 the probe checks the payload against stubPrefixes and reports substituted
instead of ok. STUB is declared only after a cache flush and a re-check
(STUB_CLEARED when the stub came from the cache).
The reliable remedy is a path the network does not intercept: your own resolver on a host
whose traffic is already inside a tunnel, published over DoH, used as dohUrlA with
probeMode "resolve" (below). Read the stub prefixes from your own network — the default
pair is one operator's example, not a universal value.
The probe speaks DNS-JSON (?name=…&type=A). A self-hosted endpoint serves wire format
and answers that request with an error, so the monitor would call a healthy primary
bad-status and move the router to a public fallback — the very path that gets
substituted.
:local probeMode "resolve" # judge by answer quality only
:local dohUrlA "https://doh.example.net/dns-query"
:local probeUrlA "" # unused in resolve mode
In resolve mode liveness is derived from the live answers: at least one sane answer and
no stub prefixes means ok. Nothing external is fetched, so the probe also works when
/tool fetch is restricted.
POSTing to /dns-query without a valid body returns 400, GET returns 405.
Both mean the TLS handshake and certificate validation succeeded — the endpoint
is alive, only the request form was wrong.
:put [:resolve example.com] # through the live resolver (DoH when set)
:put [:resolve example.com server=1.1.1.1] # direct plain query, bypasses DoH:resolve ... timeout= is not supported (bad parameter timeout).
Wiki for mikrotik-doh-failover 0.4.1 (2026-10-28, RouterOS 7.23.1 / 7.24.5).
The repository is the source of truth: when a page here disagrees with
README.md or
docs/, the
repository wins. Found a wrong statement? Open an issue with the command you ran
and its output — a claim without a measurement is not accepted.
mikrotik-doh-failover — wiki for version 0.4.3
- Getting started
- Configuration
- How it works — full flowchart
- Health monitor
- Verification
- Troubleshooting
- Changelog
- Upgrading from 0.1.0
- Zabbix monitoring
Package: PyPI — mikrotik-doh-failover · Repository: paulmann/mikrotik-doh-failover · README · Releases · Changelog