A single Go binary that benchmarks DNS servers — plain UDP/53, DNS over TLS (DoT), and DNS over HTTPS (DoH) — by querying a configurable list of FQDNs, recording response times, pinging each server, and presenting the results in a modern web UI.
Use it to find the fastest resolver for your network, to compare public resolvers against your own (Pi-hole, AdGuard Home, router), or to track resolver latency over time with scheduled scans, a history view, trends, and Prometheus metrics. The web UI is embedded in the binary; results are stored in a local SQLite database.
- Features
- How It Works
- Requirements
- Getting Started
- Configuration
- Default Configuration
- Usage
- REST API
- Prometheus Metrics
- Security Notes
- Troubleshooting
- Development
- Releases, Docker & CI
- Contributing
- License
Query results in a sortable, filterable table: server name, protocol badge (DoT/DoH), FQDN, response time (ms), status, and resolved answers. Each query is an A record lookup with recursion desired; answers list the returned IPv4 addresses.
Bar chart of average response time per server for the current run, with optional baseline overlay when comparing runs.
ICMP latency (3 echo requests, average RTT) to each configured server. If an ICMP ping cannot be performed, it falls back to timing a TCP connect: port 53 for plain DNS, 853 for DoT, and the URL's port (default 443) for DoH. DoH servers are pinged by the hostname taken from their URL.
Log of test runs from the last 24 hours with timestamp, query count, success rate, and average response time. Paginated with a configurable page size (10 / 25 / 50 / 100). Load any past run into the results view or set it as a baseline. Older runs remain in the database and are available through the API (/api/history?hours=… or from/to) and in Trends.
Select any two historical runs and see an overall delta, side-by-side bar chart, and a per-server breakdown with ms and percentage change.
Test plain UDP/53, DNS over TLS (DoT / port 853), and DNS over HTTPS (DoH / RFC 8484) side by side. Protocol is selectable per server when adding entries. Results carry a colored badge (blue = DoT, green = DoH) throughout the UI.
Pre-configured but disabled examples are included:
| Protocol | Servers |
|---|---|
| DoT | Cloudflare · Google · Quad9 |
| DoH | Cloudflare · Google · Quad9 · AdGuard |
Automatic runs on a flexible schedule: every N minutes/hours, daily, on specific weekdays, weekly, monthly, or once. Results are tagged and available in History and Compare. The scheduler checks for due schedules every 30 seconds.
Manage DNS servers (with protocol), FQDNs, and scheduled scans. Back up, restore, export, and import the configuration. Authentication and general options (dark mode, auto-update) are also configured here.
Optional authentication support — disabled by default, fully backward-compatible.
Enable Require login in Settings → Authentication to protect the web UI and API with a username and password (minimum 8 characters). On login, an HttpOnly session cookie (SameSite=Strict, 24-hour TTL) is issued. Passwords are stored as bcrypt hashes — never in plaintext. Changing the password signs out all sessions. Sessions are kept in memory, so restarting the server signs everyone out.
Enable API token authentication in Settings → Authentication and generate a token to require a Bearer token for all API access (including from the browser UI). Enabling the toggle alone does nothing until a token has been generated.
Important: API token auth on its own does not protect the auth-management endpoints. With login turned off, anyone who can reach the port can generate a new token or turn token auth off. Only Require login gives real protection; use the token for scripts in addition to login, not instead of it.
- Click Generate token to create a 256-bit random token. Copy it immediately — it is shown only once. The server stores only a SHA-256 hash.
- Pass the token in every request:
Authorization: Bearer <token> - The browser UI stores the token in
localStorageand sends it automatically — no friction after the first setup. - Click Revoke & disable to immediately invalidate the token and turn off token enforcement.
- Click Regenerate token to rotate the active token.
Both modes can be combined: browser users log in with username/password; external scripts use the Bearer token.
Light/dark theme toggle (☀️ / 🌙) in Settings → General. The preference is saved to localStorage and respects the system prefers-color-scheme on first visit. Applied before first render — no flash.
Fully responsive layout that works on phones without horizontal scroll. The DNS results table adapts its columns, forms stack vertically, and the update modal slides up as a bottom sheet on small screens.
The Trends tab shows how DNS response times have changed over time with a time-series line chart — one line per server, auto-bucketed by day (7-day and 30-day views) or hour (24-hour view). Buckets are in UTC and only successful queries are counted. Includes a period-averages summary table with weighted mean and sample count per server. DoT and DoH servers appear alongside plain UDP so all three protocols can be compared over time.
Time range selector: 24 h (hourly buckets) · 7 days (daily) · 30 days (daily).
Optional automatic update checks (off by default, enable in Settings → General; a Check for updates button is also available there). When enabled, the UI checks the latest GitHub release every 4 hours. When a new release is detected:
- A modal shows the current vs latest version and the full rendered changelog.
- Skip this version — suppresses the modal for that release; a badge in the header remains as a reminder.
- Remind me later — hides the modal until the next check.
- Update — downloads the platform-specific binary, atomically replaces the running executable, and exits so the process manager (Docker
restart: unless-stopped, systemd) restarts with the new version. The systemd unit installed byservice installrestarts afterRestartSec=120, so expect about 2 minutes of downtime there. The page auto-reloads once the server is back.
Builds with version dev never report an update. Release binaries exist for Linux (amd64, arm64, armhf) and Windows (amd64) only, so there is no in-app update on other platforms. In Docker, prefer pulling a new image: an in-place update lives only in the container's writable layer and is lost when the container is recreated.
Prometheus metrics at /metrics (latest run plus the last 5 runs) and an interactive Swagger UI at /api/docs. See Prometheus Metrics and REST API.
flowchart LR
UI["Web UI (React, embedded)"] -->|REST /api| API[HTTP server :7020]
Scripts[Scripts / automation] -->|Bearer token| API
Prom[Prometheus] -->|/metrics| API
API --> Test[Test service]
Sched[Scheduler<br/>checks every 30 s] --> Test
Test -->|A queries| DNS["DNS servers<br/>UDP / DoT / DoH"]
Test -->|ICMP or TCP| DNS
Test --> DB[("SQLite<br/>dnstester.db")]
API --> Cfg[["dnstester.json<br/>servers · FQDNs · schedules · auth"]]
- For each enabled server, every configured FQDN is queried concurrently (5-second timeout per query), and each server gets one ping probe (3 ICMP echoes, or a TCP connect fallback).
- Each completed run (manual or scheduled) is saved to
dnstester.dbin the config directory; History, Compare, Trends, and the per-run metrics read from it. - Settings, schedules, and auth settings live in
dnstester.jsonin the same directory. The file is written atomically and re-read on every request, so manual edits take effect without a restart.
- Docker (recommended), or a release binary for Linux (amd64 / arm64 / armhf) or Windows (amd64). Other platforms can build from source.
- Outbound network access to the DNS servers you test (UDP/53, TCP/853 for DoT, HTTPS for DoH).
- ICMP for ping results (optional — a TCP connect fallback is used when ICMP is unavailable).
- Internet access from the browser for the Swagger UI page (assets load from the unpkg CDN) and from the server for update checks (GitHub API).
- To build from source: Go 1.25+ and Node.js 20+.
docker run -d \
--name dnstester \
-p 7020:7020 \
--cap-add NET_RAW \
--restart unless-stopped \
-v dnstester-config:/config \
techblog/dnstester:latest--cap-add NET_RAW is optional and harmless: ICMP uses unprivileged sockets, and NET_RAW is already in Docker's default capability set. Config and the SQLite database are persisted in the dnstester-config volume (/config inside the container).
Images are multi-arch: linux/amd64, linux/arm64, linux/arm/v7.
services:
dnstester:
image: techblog/dnstester:latest
container_name: dnstester
ports:
- "7020:7020"
volumes:
- dnstester-config:/config
environment:
- CONFIG_PATH=/config
cap_add:
- NET_RAW
restart: unless-stopped
volumes:
dnstester-config:The same file ships as docker-compose.yaml:
docker compose up -dDownload the binary for your platform from the Releases page:
| Asset | Platform |
|---|---|
dnstester-<version>-linux-amd64 |
Linux x86-64 |
dnstester-<version>-linux-arm64 / -linux-aarch64 |
Linux 64-bit ARM (identical builds) |
dnstester-<version>-linux-armhf |
Linux 32-bit ARMv7 (e.g. Raspberry Pi OS 32-bit) |
dnstester-<version>-windows-amd64.exe |
Windows x86-64 |
chmod +x dnstester-<version>-linux-amd64
./dnstester-<version>-linux-amd64 --port 7020Open http://localhost:7020 in your browser.
The binary can install itself as a system service (systemd on Linux, the Service Control Manager on Windows) using kardianos/service. Run as root / Administrator:
sudo ./dnstester service install # installs and starts on port 7020
sudo ./dnstester service install --port 8080 # custom port
sudo ./dnstester service uninstall # stops and removes the serviceOn Linux, manage it with systemctl status|stop|start|restart dnstester and view logs with journalctl -u dnstester -f. On Windows use sc query|stop|start dnstester or the Services console.
Only --port is passed to the installed service; --conf is not. The service resolves its config directory from its own environment: CONFIG_PATH, then the OS default for the service user. If no user config directory can be determined, it falls back to ./dnstester relative to the working directory, and the generated systemd unit sets no WorkingDirectory. To pin the location on Linux, set it in the environment file the unit already reads:
echo 'CONFIG_PATH=/var/lib/dnstester' | sudo tee /etc/sysconfig/dnstester
sudo systemctl restart dnstesterPrerequisites: Go 1.25+ · Node.js 20+
make build-local
./dist/dnstester-<version>-linux-amd64make build-local builds the UI, embeds it, and compiles a binary for the local OS/architecture into dist/dnstester-<version>-<os>-<arch>. <version> comes from git describe.
Open http://localhost:7020 in your browser.
| Flag | Default | Description |
|---|---|---|
--port |
7020 |
Port for the web UI and API (listens on all interfaces, 0.0.0.0) |
--conf |
(see below) | Path to the config directory |
service install [--port N] |
— | Install and start as a system service (subcommand, must be the first argument) |
service uninstall |
— | Stop and remove the system service |
Run dnstester -h for built-in help. There is no --version flag; the running version is shown by GET /api/version and in the UI.
| Variable | Default | Description |
|---|---|---|
CONFIG_PATH |
OS default (see below); /config in the Docker image |
Config directory, used when --conf is not set |
--conf <path>— CLI flagCONFIG_PATHenvironment variable- OS default —
$XDG_CONFIG_HOME/dnstester(or~/.config/dnstester) on Linux,~/Library/Application Support/dnstesteron macOS,%AppData%\dnstesteron Windows. If the OS default cannot be determined (e.g.$HOMEis unset),./dnstesterrelative to the working directory is used.
The directory is created if it does not exist.
./dnstester --conf /etc/dnstester
CONFIG_PATH=/etc/dnstester ./dnstester| File | Purpose |
|---|---|
dnstester.json |
Servers, FQDNs, schedules, auto-update, and auth settings. Created on the first save; built-in defaults are used until then. |
dnstester.backup.json |
Written by Backup / POST /api/config/backup, read by Restore |
dnstester.db (+ -wal, -shm) |
SQLite database with every test run. There is no automatic retention, so it grows over time. |
Normally managed from the Settings page or the API; shown here for reference and for editing by hand.
| Key | Type | Description |
|---|---|---|
servers[] |
array | DNS servers to test |
servers[].name |
string | Display name |
servers[].address |
string | IP or host[:port] for UDP (port 53 by default) and DoT (port 853 by default); full https://… URL for DoH |
servers[].protocol |
string | "" / udp (plain DNS), dot, or doh |
servers[].enabled |
bool | Only enabled servers are tested |
fqdns[] |
array of strings | Names queried (A records) against each server |
schedules[] |
array | Scheduled scans (see below) |
auto_update |
bool | Enable periodic update checks in the UI (default false) |
auth.enabled |
bool | Require username/password login |
auth.username |
string | Login username |
auth.password_hash |
string | bcrypt hash — set via Settings / PUT /api/auth/settings |
auth.api_token_enabled |
bool | Require a Bearer token for the API (only enforced once a token has been generated) |
auth.api_token_hash |
string | SHA-256 hash of the API token — set via POST /api/auth/token |
Schedule entries:
| Key | Used by | Description |
|---|---|---|
id |
all | Generated by the server |
name |
all | Required |
enabled |
all | Only enabled schedules run |
type |
all | interval, daily, weekdays, weekly, monthly, or once |
interval_minutes |
interval |
Run every N minutes (> 0) |
time_of_day |
daily, weekdays, weekly, monthly |
HH:MM, 24-hour, server local time |
weekdays |
weekdays |
Days to run, 0 = Sunday … 6 = Saturday |
weekday |
weekly |
Single day, 0 = Sunday … 6 = Saturday |
day_of_month |
monthly |
1–31 |
run_at |
once |
RFC 3339 timestamp, e.g. 2026-10-01T08:00:00+03:00 |
| Name | Address |
|---|---|
| Cloudflare | 1.1.1.1 |
| Cloudflare Alt | 1.0.0.1 |
| 8.8.8.8 | |
| Google Alt | 8.8.4.4 |
| Quad9 | 9.9.9.9 |
| OpenDNS | 208.67.222.222 |
| OpenDNS Alt | 208.67.220.220 |
| AdGuard | 94.140.14.14 |
DoT addresses are stored without a port; :853 is added at query time.
| Name | Protocol | Address |
|---|---|---|
| Cloudflare DoT | DoT | 1.1.1.1 |
| Google DoT | DoT | 8.8.8.8 |
| Quad9 DoT | DoT | 9.9.9.9 |
| Cloudflare DoH | DoH | https://cloudflare-dns.com/dns-query |
| Google DoH | DoH | https://dns.google/dns-query |
| Quad9 DoH | DoH | https://dns.quad9.net/dns-query |
| AdGuard DoH | DoH | https://dns.adguard-dns.com/dns-query |
google.com · cloudflare.com · github.com · microsoft.com · apple.com
All servers and FQDNs are fully configurable from the Settings page or the API.
The UI has five tabs (the active tab is kept in the URL hash, e.g. #history):
- Results — click Run Test to query every enabled server. Shows the response-time graph, the results table, and ping results for the latest run. The latest result survives restarts (it is loaded from the database).
- Compare — pick two runs to see overall and per-server deltas.
- History — runs from the last 24 hours; load one into Results or set it as a baseline for the graph.
- Trends — response-time lines per server over 24 h / 7 days / 30 days.
- Settings — servers and FQDNs, backup/restore/export/import, scheduled scans, General (dark mode, auto-update), and Authentication.
# Run a test and get the full result as JSON
curl -s -X POST http://localhost:7020/api/test/run
# With API token authentication enabled
curl -s -X POST -H "Authorization: Bearer $DNSTESTER_TOKEN" http://localhost:7020/api/test/run
# Runs from the last 7 days
curl -s "http://localhost:7020/api/history?hours=168&limit=100"
# Add a schedule that runs every 30 minutes
curl -s -X POST http://localhost:7020/api/schedules \
-H 'Content-Type: application/json' \
-d '{"name":"Every 30 min","enabled":true,"type":"interval","interval_minutes":30}'A test run is synchronous: the request returns when all queries and pings have finished (each has a 5-second timeout).
Interactive Swagger UI at /api/docs · OpenAPI spec at /api/openapi.json.
When API token authentication is enabled, click the Authorize button in Swagger UI and enter your Bearer token. The token persists across page reloads (persistAuthorization: true).
Unless marked otherwise, every endpoint requires authentication once login is enabled, or once API token auth is enabled and a token has been generated (a session cookie or a valid Bearer token is accepted). Enabling token auth without generating a token leaves the API open. With authentication disabled (the default), the API is open.
| Method | Endpoint | Auth required | Description |
|---|---|---|---|
GET |
/api/auth/status |
No | Returns auth configuration and whether the request is authenticated |
POST |
/api/auth/login |
No | Log in with {"username","password"}; sets a session cookie |
POST |
/api/auth/logout |
No | Clears the session cookie |
PUT |
/api/auth/settings |
Session (when login is enabled) | Enable/disable login or token auth; set username and password |
POST |
/api/auth/token |
Session (when login is enabled) | Generate a new API token (returned once in plaintext) |
DELETE |
/api/auth/token |
Session (when login is enabled) | Revoke the token and disable API token auth |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/test/run |
Run a DNS test, save it, and return the results |
POST |
/api/test/run |
Same as GET |
GET |
/api/test/latest |
Return the most recent test results (404 if none) |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/history?limit=25&offset=0 |
Paginated list of runs — returns { total, items } |
GET |
/api/history/{id} |
Get a specific historical run |
GET |
/api/compare?a={id}&b={id} |
Compare two runs |
/api/history query parameters: limit (default 100, max 500), offset, hours (look-back window, default 24), from / to (RFC 3339, override hours), scheduled=true (scheduled runs only).
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/trends?hours=168 |
Per-server avg response time bucketed by day (default 7 days) or hour (≤48 h); hours max 8760 |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/settings |
Get current configuration (password and token hashes are stripped) |
PUT |
/api/settings |
Replace current configuration (the auth section is ignored — use /api/auth/*). Only this endpoint (and its /api/config alias) preserves auth; restore and import replace it |
POST |
/api/config/backup |
Create a config backup |
POST |
/api/config/restore |
Restore config from backup |
GET |
/api/config/export |
Download config as JSON |
POST |
/api/config/import |
Import config from JSON (max 1 MiB); replaces the whole file, including the auth section |
/api/config(GET/PUT) are backward-compatible aliases for/api/settings.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/schedules |
List scheduled scans |
POST |
/api/schedules |
Create a scheduled scan |
PUT |
/api/schedules/{id} |
Update a scheduled scan |
DELETE |
/api/schedules/{id} |
Delete a scheduled scan |
See schedule entries for the request body fields.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/version |
Current binary version |
GET |
/api/update/check |
Check GitHub for a newer release |
POST |
/api/update/apply |
Download the binary from the URL given in {"download_url": "…"} (the UI sends the GitHub release asset URL), replace the running executable, and restart |
| Method | Endpoint | Description |
|---|---|---|
GET |
/metrics |
Prometheus metrics (response times, last 5 run results) |
GET |
/api/docs |
Swagger UI |
GET |
/api/openapi.json |
OpenAPI 3.0 spec |
/metrics exposes the standard Go/process collectors plus the following gauges (all times in seconds):
| Metric | Labels | Description |
|---|---|---|
dnstester_dns_response_seconds |
server_name, server_addr, fqdn, status |
DNS response time from the latest run in this process |
dnstester_ping_latency_seconds |
server_name, server_addr, status |
Ping latency from the latest run in this process |
dnstester_last_run_timestamp_seconds |
— | Unix start time of the latest run |
dnstester_last_run_duration_seconds |
— | Duration of the latest run |
dnstester_test_runs_total |
— | Number of runs stored in the database |
dnstester_run_info |
run_id, started_at, status |
Always 1; one series per run for the last 5 runs |
dnstester_run_duration_seconds |
run_id |
Duration of each of the last 5 runs |
dnstester_run_dns_response_seconds |
run_id, server_name, server_addr, fqdn, status |
DNS response time per run (last 5 runs) |
dnstester_run_ping_latency_seconds |
run_id, server_name, server_addr, status |
Ping latency per run (last 5 runs) |
For DNS metrics status is ok or error (timeouts count as error); for ping metrics it is ok, error, or timeout. On dnstester_run_info, status is the run status (e.g. completed). The latest-run metrics are empty after a restart until a test runs. Tests only run when triggered, so add a scheduled scan to keep the metrics fresh.
Example scrape config:
scrape_configs:
- job_name: dnstester
static_configs:
- targets: ["dnstester:7020"]- Authentication is off by default. Anyone who can reach the port can run tests, change settings and schedules, and trigger the self-update endpoint. Keep the service on a trusted network, or enable Require login before exposing it, ideally behind a TLS reverse proxy.
- API token auth alone is not enough. With login off, the auth-management endpoints are unprotected, so anyone who can reach the port can issue a new token or disable token auth. Only Require login protects them.
- The server speaks plain HTTP. The session cookie is not marked
Secure, so use HTTPS via a reverse proxy when logging in over untrusted networks. /metrics,/api/docs, and/api/openapi.jsonare always unauthenticated.- Passwords are stored as bcrypt hashes and the API token as a SHA-256 hash in
dnstester.json.GET /api/settingsstrips them, but Export, Restore, and Import return the hashes in their responses — treat exported files and the config volume as sensitive. Restore and Import replace theauthsection wholesale, so anyone with API access (including a Bearer-token holder) can change the login credentials this way. - DoT queries do not verify the server's TLS certificate (servers are often addressed by IP). The tool measures latency; it does not validate resolver identity.
POST /api/update/applydownloads the binary from whatever URL the request supplies. The URL is not restricted to GitHub or HTTPS, and the download is not checked (no checksum or signature). Anyone who can call this endpoint can make the server download and run arbitrary code, which is one more reason to enable Require login.
- Ping shows
unreachable. ICMP could not be used and the TCP connect fallback to the server's DNS port also failed. When ICMP is unavailable but TCP works, the ping column shows the TCP connect time instead. The app uses unprivileged ICMP sockets; on Linux these are allowed bynet.ipv4.ping_group_range(Docker's default permits them). - Ping shows
timeout. ICMP echoes were sent but no replies came back (e.g. the server or a firewall drops ICMP). The TCP fallback is not tried in this case. - Scheduled scans run at the wrong hour.
time_of_dayuses the server's local time zone. The Docker image does not include time-zone data, so containers use UTC unless you mount the host's zone file, e.g.-v /etc/localtime:/etc/localtime:ro. SettingTZalone does not work, because the image has no zone database. - The History tab is missing older runs. It shows the last 24 hours. Use Trends or
/api/history?hours=…for older data. /api/docsis blank. Swagger UI assets load fromunpkg.com; the browser needs internet access.- Locked out after enabling login. Set
"enabled": false(and"api_token_enabled": false) underauthindnstester.json. The file is re-read on every request, so no restart is needed. go buildfails withpattern dist: no matching files found. The UI has not been built yet. Usemake build-local(or build the UI and copyweb/ui/disttoweb/dist).
make build # Cross-compile production binaries (all release targets)
make build-dev # Same targets, dev build mode
make build-local # Build for local architecture only (fastest)
make test # go test ./...
make lint # go vet ./...
make clean # Remove dist/ and web/dist/*, then write a placeholder web/dist/index.html
# (fails on a fresh clone where web/dist does not exist yet)
make next-version # Print the next CalVer versionscripts/build.sh builds the UI (npm ci && npm run build in web/ui), copies it to web/dist (embedded with go:embed), and cross-compiles with CGO_ENABLED=0 into dist/. The TARGETS environment variable overrides the target list. make test and make lint also need web/dist, so run a build first.
UI dev server (proxies /api to the Go server on :7020):
cd web/ui && npm run devcmd/dnstester/ main entry point, flags, service install/uninstall
internal/auth/ sessions, password hashing, API tokens, auth middleware
internal/config/ dnstester.json load/save, defaults, backup/restore
internal/handler/ HTTP handlers, embedded OpenAPI spec and Swagger UI
internal/metrics/ Prometheus collector
internal/model/ shared data types
internal/server/ HTTP routing and SPA serving
internal/service/ DNS (UDP/DoT/DoH), ping, test runner, scheduler, self-update
internal/store/ SQLite storage, history, compare, trends
web/ui/ React + TypeScript + Vite + Tailwind frontend
web/embed.go embeds web/dist into the binary
scripts/ build.sh, next-version.sh
Tech stack: Go 1.25, miekg/dns, pro-bing, modernc.org/sqlite (CGO-free), prometheus/client_golang, kardianos/service; React 18, TanStack Query, Recharts, Tailwind CSS.
Multi-platform Docker images are published to Docker Hub after every GitHub release:
techblog/dnstester:latest
techblog/dnstester:<version> # e.g. 2026.5.7
Supported platforms: linux/amd64 · linux/arm64 · linux/arm/v7 (armhf)
Releases follow CalVer (YYYY.M.PATCH) and are triggered manually via the GitHub Actions Release workflow, which tags the commit, builds all targets, and attaches the binaries to a GitHub Release. The Docker Build workflow runs automatically after each successful release.
Issues and pull requests are welcome. Please run make test and make lint before opening a PR, and keep the OpenAPI spec (internal/handler/openapi.go) and this README in sync with API changes.
Licensed under the Apache License 2.0.





