Skip to content

Repository files navigation

Pharos

CI CodeQL Go report card Go 1.25 MIT

Pharos is a DNS server in a single Go binary. It answers from your own zones, filters what you do not want resolved, caches what it learns, and finds the rest by walking the delegation chain from the root servers itself. It speaks plain UDP and TCP, DNS over TLS and DNS over HTTPS, in both directions.

The DNS wire format is implemented here rather than imported: name compression, EDNS negotiation and truncation policy are part of this repository and part of its test suite.

Try it

git clone https://github.com/cansarihan/pharos.git
cd pharos
make build
./bin/pharos -config configs/pharos.demo.yaml

The demo profile listens on 127.0.0.1:15353 and needs no privileges. In another terminal:

$ dig +short @127.0.0.1 -p 15353 nas.home.arpa
192.168.10.20                     # answered from the local zone file

$ dig +short @127.0.0.1 -p 15353 example.com
104.20.23.154                     # resolved from the root servers, then cached

$ dig @127.0.0.1 -p 15353 ads.example.com | grep status
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN   # stopped by the block list

$ ./bin/pharosctl -server 127.0.0.1:15353 query github.com MX
github.com. MX NOERROR from 127.0.0.1:15353 in 261.333ms

For a busier picture, make demo starts the resolver and four client containers that generate a realistic query mix, and publishes the dashboard on http://127.0.0.1:18053.

What happens to a query

Every query, on every transport, is decoded into the same message and walked through one pipeline. The first stage that can answer does, and nothing after it runs.

flowchart TD
    A["UDP, TCP, DoT or DoH"] --> B{"Client allowed<br/>and within its rate?"}
    B -->|no| R1["REFUSED"]
    B -->|yes| C{"Name inside<br/>a local zone?"}
    C -->|yes| R2["Authoritative answer<br/>or referral with glue"]
    C -->|no| D{"On a block list<br/>and not allowlisted?"}
    D -->|yes| R3["NXDOMAIN, zero address<br/>or REFUSED"]
    D -->|no| E{"Cached and<br/>still fresh?"}
    E -->|yes| R4["Cached answer,<br/>TTLs decremented"]
    E -->|"expired, inside the grace window"| R5["Stale answer"]
    E -->|no| F["Single flight:<br/>duplicates share one resolution"]
    F --> G["Recursive walk or forwarders"]
    G --> H["Store and answer"]
Loading

That ordering is deliberate. Local names never leave the building, blocked names never cost an upstream query, and a burst of identical cache misses produces one outbound resolution rather than a hundred.

Finding an answer

In recursive mode Pharos starts at the root and asks its way down, exactly as a resolver is supposed to.

sequenceDiagram
    participant P as Pharos
    participant R as Root
    participant T as com
    participant A as example.com

    P->>R: com NS
    R-->>P: referral to the com servers, with glue
    P->>T: example.com NS
    T-->>P: referral to the example.com servers, with glue
    P->>A: www.example.com A
    A-->>P: answer
Loading
  • QNAME minimisation is on by default, so the root is asked about com and never about www.example.com. The test suite asserts this: it builds a root, TLD and zone hierarchy in memory and fails if the full name reaches a parent server.
  • Glue is used when a referral carries it. When it does not, the name server address is resolved on its own, with the depth counter carried across so a loop cannot run away.
  • CNAME chains restart from the root for the new target and come back as a single answer.
  • Server selection shuffles the addresses for a zone and moves on when one times out or answers SERVFAIL.

Prefer to lean on someone else's resolver? Switch to forward mode and list upstreams:

resolver:
  mode: forward
  strategy: race
  forwarders:
    - name: cloudflare
      protocol: dot
      address: 1.1.1.1:853
      hostname: cloudflare-dns.com
    - name: quad9
      protocol: doh
      url: https://dns.quad9.net/dns-query

strategy picks between first with failover, random, and race, which asks every upstream at once and takes the first answer. DNS over TLS connections are kept open and reused between queries.

Serving your own names

Zones are ordinary master files, parsed with $ORIGIN, $TTL, @, relative names, parentheses, comments, quoted text and time units such as 1h or 2w.

$ORIGIN home.arpa.
$TTL 3600

@       IN SOA  ns1.home.arpa. hostmaster.home.arpa. (
                2026082601 7200 3600 1209600 3600 )
@       IN NS   ns1.home.arpa.
ns1     IN A    192.168.10.2

router  IN A    192.168.10.1
nas     IN A    192.168.10.20
nas     IN AAAA fd00::20
git     IN CNAME nas
*.lab   IN A    192.168.20.5

Answering follows the order a resolver expects: exact match, then wildcard, then a delegation below the zone returned as a referral with glue, and finally NXDOMAIN carrying the zone SOA. A name that exists without the requested type returns NOERROR with an empty answer and the SOA, which is what lets the other side cache the negative result instead of asking again.

Filtering

Format Example line Notes
hosts 0.0.0.0 ads.example.com Only null addresses count, so a hosts file with real entries stays intact
domains ads.example.com One name per line
adblock ||ads.example.com^ Domain rules only, cosmetic rules ignored

Sources are files or HTTPS URLs, merged into one set with the origin remembered per entry so the dashboard can say which list stopped a query. Matching walks the name upwards, so blocking example.com also blocks cdn.example.com but never notexample.com, and the allowlist is consulted first at every level. What a blocked client receives is up to you: nxdomain, zero_ip, or refused.

Caching

The cache is keyed by name, type, class and the DNSSEC OK bit, so a DNSSEC request never receives an answer assembled for a plain one.

  • Lifetime is the smallest TTL in the answer, clamped between min_ttl and max_ttl.
  • Negative answers use the SOA minimum when it is present, negative_ttl otherwise. SERVFAIL is never cached.
  • TTLs are decremented by the age of the entry on every read, so a client never sees a frozen countdown.
  • serve_stale keeps expired answers usable for a grace period, which holds a network together while an upstream is briefly unreachable.

Watching it run

The control plane is served by the same binary. The signal band across the top is one bar per second, separated into cache hits, local zone answers, upstream resolutions and blocked queries, so the shape of the traffic is readable at a glance rather than hidden behind an average.

Pharos control plane

Everything the dashboard shows is available to scripts and terminals through pharosctl, which also carries a query command that speaks the wire protocol directly.

pharosctl

pharosctl status | stats | queries [limit] | top [board] | cache [flush]
pharosctl zones | upstreams | blocklist [reload] | reload
pharosctl query <name> [type]
pharosctl check <file>

Prometheus metrics are on /metrics:

Metric Type Labels
pharos_queries_total counter protocol, type, rcode, result
pharos_query_duration_seconds histogram result
pharos_blocked_total counter none
pharos_cache_entries, pharos_cache_hit_rate gauge none
pharos_upstream_errors_total counter upstream
pharos_outbound_queries_total, pharos_config_reloads_total counter none

The result label separates resolved, cached, stale, authoritative, blocked, refused and failed, so the answer mix needs no joins. Each query also produces one structured log record with the client, name, type, transport, result, response code and duration.

Configuration

One YAML file. Unknown fields are rejected, ${ENVIRONMENT_VARIABLES} are expanded, and relative paths resolve against the file itself. pharos -check validates without starting anything.

server:
  udp: { enabled: true, listen: "0.0.0.0:53" }
  tcp: { enabled: true, listen: "0.0.0.0:53" }
  tls:
    enabled: true
    listen: "0.0.0.0:853"
    cert_file: /etc/pharos/tls/server.crt
    key_file: /etc/pharos/tls/server.key
  https:
    enabled: true
    listen: "0.0.0.0:443"
    path: /dns-query
    cert_file: /etc/pharos/tls/server.crt
    key_file: /etc/pharos/tls/server.key
  udp_payload_size: 1232
  query_timeout: 5s
  max_concurrent: 2048
Section Key settings
server Listener addresses per transport, EDNS payload size, query deadline, concurrency
resolver mode, minimize_qname, attempts, max_depth, max_cname_chain, forwarders and strategy
cache max_entries, min_ttl, max_ttl, negative_ttl, serve_stale
blocking response, ttl, sources, allowlist, denylist, update_interval
zones Origin and file path per authoritative zone
access allow and deny networks, and a per client rate_limit
admin Listen address, optional bearer token, ui and metrics toggles
logging level, format, and query_log to turn the per query record off

configs/pharos.yaml documents every option; configs/pharos.demo.yaml is the unprivileged profile used above. A reload happens on SIGHUP or through POST /api/v1/reload, and keeps the warm cache when cache settings did not change.

The admin API mirrors the CLI: /api/v1/status, /stats, /series, /queries, /top, /cache, /blocklist, /access, /upstreams, /zones, plus POST /reload, /cache/flush and /blocklist/reload. When admin.token is set, everything except /healthz requires it.

Running it for real

[Service]
Type=simple
User=pharos
ExecStart=/usr/local/bin/pharos -config /etc/pharos/pharos.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/pharos
LimitNOFILE=65535

CAP_NET_BIND_SERVICE is what lets an unprivileged user hold port 53; the container image needs --cap-add NET_BIND_SERVICE for the same reason, or publish a high port and map it.

Numbers

Measured on an Apple M5 with ten cores, with the load generator, the resolver and the cache all on one host, so treat these as the cost of the server rather than the capacity of a deployment.

Scenario Throughput p50 p95 p99
Cached answers over UDP, 24 workers 17,313 q/s 1.19 ms 2.88 ms 3.80 ms
The same through Docker port publishing 14,576 q/s 1.49 ms 2.97 ms 4.06 ms

The benchmark client opens a fresh socket per query, which is most of that latency: the resolver's own handling time for the same answers has a median of 0.01 ms. Wire codec, from go test -bench: 513 ns to pack a query with EDNS, 780 ns to unpack a four answer response.

Building and testing

make test     # unit and integration tests
make race     # the whole suite under the race detector
make cover    # coverage summary
make lint     # golangci-lint
make demo     # container demo with four client workloads

The suite covers the codec against malformed input and compression pointer attacks, the cache including stale serving and negative TTLs, zone parsing and answering, block list formats and precedence, access control and rate limiting, the query pipeline, and every listener including DNS over TLS and DNS over HTTPS with a generated certificate. Recursion is tested against an in memory root, TLD and zone hierarchy, which asserts the exact delegation path taken.

docs/architecture.md goes deeper: state ownership, the reload protocol, the failure model, and why the boundaries sit where they do.

What is next

  • DNSSEC validation with a trust anchor and negative proof handling
  • Zone transfers, AXFR and IXFR, with NOTIFY
  • Per client policy groups, so different networks get different block lists
  • Prefetching popular entries before their TTL expires
  • DNS64 and NAT64 for IPv6 only networks

License

MIT. Copyright (c) 2026 Can Sarıhan.

About

Recursive DNS resolver with authoritative zones, block list filtering, DoT and DoH, and a live control plane

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages