Skip to content
Mikhail Deynekin edited this page Oct 28, 2026 · 5 revisions

How it works — full flowchart

Seven diagrams, in the order that makes them easiest to read:

  1. Component map — what talks to what
  2. Installation order
  3. The DNS query path, step by step
  4. Health monitor algorithm — exactly as coded
  5. Monitor state machine
  6. Failover in time — who does what
  7. Diagnosing "the site does not open"

Everything below reflects routeros/05-dns-health-monitor.rsc in v0.4.0, verified on RouterOS 7.23.1.


1. Component map

flowchart LR
    subgraph LAN["LAN"]
        C1["client 1<br/>DHCP: DNS = router"]
        C2["client 2<br/>hardcoded 8.8.8.8"]
    end

    subgraph R["RouterOS"]
        NAT["dstnat redirect<br/>UDP/TCP 53 -> router"]
        RES["router resolver<br/>/ip dns"]
        CACHE["DNS cache"]
        RULES{"use-doh-server<br/>set?"}
        MON["health monitor<br/>script + 60s scheduler"]
        ST["/ip dns static<br/>bootstrap A records"]
    end

    subgraph UP["Upstreams"]
        A["DoH A — primary<br/>standard Cloudflare"]
        B["DoH B — fallback<br/>Google"]
        P["plain servers<br/>1.1.1.1, 1.0.0.1<br/>bootstrap only"]
    end

    C1 -->|"query to router"| RES
    C2 -->|"query to 8.8.8.8"| NAT
    NAT -->|"rewritten to router"| RES
    RES --> CACHE
    CACHE -->|"miss"| RULES
    RULES -->|"yes"| A
    RULES -.->|"after failover"| B
    RULES -.->|"never as fallback"| P
    MON -->|"/tool fetch probe<br/>does not touch /ip dns"| A
    MON -->|"probe before switching"| B
    MON -->|"switch / rollback"| RULES
    ST -->|"endpoint hostnames<br/>resolve without DNS"| MON
    MON -->|"reads answers"| RES

    CA["trusted CA<br/>SSL.com ECC 2022"] -->|"TLS validation"| A
    CA -->|"TLS validation"| B
    NTP["NTP"] -->|"correct clock"| CA
Loading

2. Installation order

flowchart TD
    START["start"] --> PRE{"RouterOS 7.20+?<br/>NTP synchronized?<br/>device-mode known?"}
    PRE -->|"no"| STOP1["stop: fix preconditions"]:::bad
    PRE -->|"yes"| UPL["upload certs/cloudflare-doh-ca.crt<br/>to the router Files"]

    UPL --> S00["00-install-ca.rsc<br/>import CA, set trusted=yes"]
    S00 --> S01["01-configure-doh.rsc<br/>servers, use-doh-server,<br/>bootstrap records for A and B"]
    S01 --> S02["02-security-policies.rsc<br/>redirect 53, accept LAN, drop WAN"]
    S02 --> S05["05-dns-health-monitor.rsc<br/>script + scheduler"]

    S05 --> CHK{"/certificate print where trusted=yes<br/>:resolve example.com"}
    CHK -->|"real address"| OK["done: monitor runs every interval (60s)"]:::good
    CHK -->|"8.6.112.x / 8.47.69.x"| STUB["stub pair — not a DoH failure<br/>see troubleshooting 6.10"]:::warn
    CHK -->|"empty / error"| FIX["check CA, clock, bootstrap records"]:::bad

    classDef good fill:#d7f5dd,stroke:#2f7d4f,color:#000
    classDef warn fill:#fff4cc,stroke:#a67c00,color:#000
    classDef bad fill:#ffd6d6,stroke:#a33,color:#000
Loading

3. The DNS query path, step by step

flowchart TD
    Q["client asks for cdn.tailwindcss.com"] --> SRC{"client's resolver<br/>= router?"}
    SRC -->|"yes"| LOCAL["answered locally by the router"]
    SRC -->|"no, e.g. 8.8.8.8"| REDIR["dstnat redirect rewrites<br/>destination to the router"]
    REDIR --> LOCAL

    LOCAL --> HIT{"in cache?"}
    HIT -->|"yes"| ANS["answer returned<br/>TTL from cache"]
    HIT -->|"no"| DOH{"use-doh-server set?"}
    DOH -->|"yes"| QUERY["HTTPS query to the active endpoint<br/>certificate verified"]
    DOH -->|"no"| PLAIN["plain query to servers=<br/>bootstrap path"]
    QUERY --> CACHEIT["answer cached"]
    PLAIN --> CACHEIT
    CACHEIT --> ANS

    ANS --> KIND{"what came back?"}
    KIND -->|"104.x / 172.x"| REAL["real address<br/>works everywhere"]:::good
    KIND -->|"8.6.112.x / 8.47.69.x"| PAIR["stub pair<br/>works only where reachable"]:::warn
    KIND -->|"0.0.0.0"| BLOCK["filtering resolver blocked it"]:::warn

    classDef good fill:#d7f5dd,stroke:#2f7d4f,color:#000
    classDef warn fill:#fff4cc,stroke:#a67c00,color:#000
Loading

4. Health monitor algorithm

Exactly the branches of 05-dns-health-monitor.rsc.

flowchart TD
    RUN["scheduler fires every interval (60s)"] --> DEVICE{"device-mode == advanced?"}
    DEVICE -->|"no (home)"| MANUAL["state = manual-only<br/>log MONITOR_DISABLED_BY_DEVICE_MODE<br/>STOP"]:::warn
    DEVICE -->|"yes"| CLOCK{"year from /system clock is 2025 or later?"}
    CLOCK -->|"no"| CLOCKBAD["log error: clock looks wrong<br/>cert validation impossible<br/>STOP"]:::bad
    CLOCK -->|"yes"| CANARY["resolve 3 canaries:<br/>example.com, cdnjs.cloudflare.com,<br/>cdn.tailwindcss.com"]
    CANARY --> JUDGE["judge CONTENT:<br/>empty / 0.0.0.0 / stub prefix = bad"]
    JUDGE --> GOOD{"any real address?"}

    GOOD -->|"yes"| RESET["DNSFailoverAttempts = 0"]
    RESET --> WASB{"state == fallback or recovering?"}
    WASB -->|"yes"| BACK["set use-doh-server = A<br/>flush cache<br/>state = healthy<br/>log warning"]:::good
    WASB -->|"no"| HSTATE{"state != healthy?"}
    HSTATE -->|"yes"| HLOG["state = healthy<br/>log info once"]:::good
    HSTATE -->|"no"| NOTHING["no action, no log"]:::good

    GOOD -->|"no"| STUBSEEN{"any answer<br/>was the stub pair?"}
    STUBSEEN -->|"yes"| STUBSTATE{"state != stub?"}
    STUBSTATE -->|"yes"| STUBLOG["state = stub<br/>log warning once:<br/>pair unreachable here?<br/>switching does not help"]:::warn
    STUBSTATE -->|"no"| NOTHING2["no action, no log"]:::warn

    STUBSEEN -->|"no"| PROBEA["probe endpoint A<br/>/tool fetch + DNS-JSON"]
    PROBEA --> ALIVEA{"probeA == ok?"}
    ALIVEA -->|"yes"| FLUSH["flush cache<br/>state = suspect<br/>log SUSPECT once<br/>re-check next interval"]:::warn

    ALIVEA -->|"no"| LOGDOWN{"state not suspect/fallback?"}
    LOGDOWN -->|"yes"| ERR1["log error:<br/>primary DoH unreachable"]:::bad
    LOGDOWN -->|"no"| SKIPLOG["no repeat log"]
    ERR1 --> CANDO
    SKIPLOG --> CANDO

    CANDO{"applyChanges AND attempts below maxAttempts?"}
    CANDO -->|"no"| KEEP1["keep current configuration"]:::warn
    CANDO -->|"yes"| PROBEB["probe endpoint B<br/>/tool fetch + DNS-JSON"]
    PROBEB --> ALIVEB{"probeB == ok?"}
    ALIVEB -->|"no"| KEEP2["state = degraded<br/>log DEGRADED once:<br/>both endpoints unreachable"]:::bad

    ALIVEB -->|"yes"| SWITCH["attempts + 1<br/>set use-doh-server = B<br/>flush cache, wait 3 s"]
    SWITCH --> VERIFY{"resolve example.com<br/>returns anything?"}
    VERIFY -->|"yes"| SWOK["state = fallback<br/>log warning with the answer"]:::good
    VERIFY -->|"no"| ROLL["set use-doh-server = A<br/>flush cache<br/>state = degraded<br/>log DEGRADED: check CA for B"]:::bad

    classDef good fill:#d7f5dd,stroke:#2f7d4f,color:#000
    classDef warn fill:#fff4cc,stroke:#a67c00,color:#000
    classDef bad fill:#ffd6d6,stroke:#a33,color:#000
Loading

5. Monitor state machine

stateDiagram-v2
    [*] --> healthy: first clean run
    [*] --> clock_invalid: wrong date
    [*] --> manual_only: device-mode=home

    healthy --> suspect: canary fails once
    suspect --> healthy: canary passes
    suspect --> fallback: failureThreshold bad runs, fallback verified

    fallback --> recovering: primary passes once
    recovering --> healthy: recoveryThreshold good runs
    recovering --> fallback: primary fails again

    suspect --> degraded: both endpoints unreachable
    fallback --> degraded: fallback stopped resolving

    healthy --> stub: answers became the pair
    suspect --> stub: answers became the pair
    stub --> healthy: real answers again
    stub --> degraded: answers empty too

    note right of stub
        No failover here on purpose:
        a second endpoint returns
        the same stub pair
    end note

    note right of fallback
        failureThreshold / recoveryThreshold
        are the anti-flap hysteresis
    end note
Loading

6. Failover in time

sequenceDiagram
    autonumber
    participant LAN as LAN client
    participant R as Router resolver
    participant A as DoH A primary
    participant B as DoH B fallback
    participant M as Monitor

    LAN->>R: resolve cdn.tailwindcss.com
    R->>A: HTTPS query
    A-->>R: 8.47.69.0 / 8.6.112.0
    R-->>LAN: answer (the stub pair)

    Note over M: one interval later
    M->>R: :resolve 3 canaries
    R-->>M: stub pair only
    M->>M: content check fails, stubSeen = 1
    M->>M: state = stub, one warning
    Note right of M: no switch — B would answer the same

    Note over A: primary goes down
    M->>R: :resolve canaries
    R-->>M: empty on error
    M->>A: /tool fetch probe
    A--xM: unreachable
    M->>B: /tool fetch probe
    B-->>M: Status 0
    M->>R: set use-doh-server = B, flush cache
    M->>R: wait 3 s, :resolve example.com
    R->>B: HTTPS query
    B-->>R: 104.20.23.154
    R-->>M: address
    M->>M: state = fallback, log warning

    Note over A: primary recovers
    M->>R: :resolve canaries
    R->>B: HTTPS query
    B-->>R: real addresses
    R-->>M: real answers and state = fallback
    M->>R: set use-doh-server = A, flush cache
    M->>M: state = healthy, attempts = 0, log warning
Loading

7. Diagnosing "the site does not open"

flowchart TD
    S["browser cannot open a CDN name"] --> D1{"monitor log says<br/>which state?"}

    D1 -->|"healthy"| D2{"resolve the name<br/>from a client"}
    D2 -->|"empty"| D3["client-side problem:<br/>its own DNS/tunnel"]:::warn
    D2 -->|"real address"| D4["not DNS: TLS, MTU,<br/>proxy or the site itself"]:::warn
    D2 -->|"stub pair"| D5{"curl --resolve name:443:8.6.112.0<br/>from the client"}

    D1 -->|"stub"| D5
    D5 -->|"302 in ~0.2 s"| D6["pair is reachable here:<br/>DNS is not the cause"]:::good
    D5 -->|"timeout"| D7{"do you have a tunnel<br/>to a working vantage?"}
    D7 -->|"yes"| F1["route the pair through the tunnel<br/>address-list + routing mark"]:::good
    D7 -->|"no"| F2["pin reachable addresses<br/>/ip dns static for the names you need"]:::good

    D1 -->|"degraded"| D8{"log says both endpoints<br/>unreachable?"}
    D8 -->|"yes"| D9{"bootstrap records<br/>for both hosts present?"}
    D9 -->|"no"| F3["add bootstrap A records,<br/>re-run the monitor"]:::good
    D9 -->|"yes"| F4["no DNS path at all:<br/>check WAN, device-mode fetch"]:::bad
    D8 -->|"no"| F5["cache flushed, wait one cycle;<br/>if still dead check the CA"]:::warn

    classDef good fill:#d7f5dd,stroke:#2f7d4f,color:#000
    classDef warn fill:#fff4cc,stroke:#a67c00,color:#000
    classDef bad fill:#ffd6d6,stroke:#a33,color:#000
Loading

Reading the diagrams

Symbol Meaning
solid arrow data path that happens on every query
dotted arrow path taken only after a failover
green node healthy outcome
yellow node degraded but explained — no action needed on the router
red node needs a human

The single most important thing these diagrams encode: stub is not a failure of DoH. The monitor deliberately does not fail over there, because the second endpoint returns the same pair. What matters is whether that pair is reachable from the network — and that question can only be answered from a client, not from the router.

Clone this wiki locally