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.
git clone https://github.com/cansarihan/pharos.git
cd pharos
make build
./bin/pharos -config configs/pharos.demo.yamlThe 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.333msFor 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.
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"]
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.
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
- QNAME minimisation is on by default, so the root is asked about
comand never aboutwww.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-querystrategy 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.
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.5Answering 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.
| 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.
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_ttlandmax_ttl. - Negative answers use the SOA minimum when it is present,
negative_ttlotherwise. 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_stalekeeps expired answers usable for a grace period, which holds a network together while an upstream is briefly unreachable.
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.
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 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.
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.
[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=65535CAP_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.
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.
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 workloadsThe 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.
- 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
MIT. Copyright (c) 2026 Can Sarıhan.

