{"id":47698730,"url":"https://github.com/ron-png/oxi-dns","last_synced_at":"2026-04-15T23:01:02.623Z","repository":{"id":347439498,"uuid":"1194074230","full_name":"ron-png/oxi-dns","owner":"ron-png","description":"DNS sinkhole ad blocker written in Rust - block ads, trackers, and malware network-wide. Supports DNS-over-TLS (DoT), DNS-over-HTTPS (DoH), DNS-over-QUIC (DoQ), and IPv6. Single binary with web dashboard, auto-updates with zero-downtime restarts.","archived":false,"fork":false,"pushed_at":"2026-04-13T09:58:39.000Z","size":2287,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-13T11:02:19.358Z","etag":null,"topics":["adblock","adblocker","adguard","adguardhome","adguardhome-alternative","dns","dns-server","dns-sinkhole","doh","doq","dot","encrypted-dns","pi-hole","pihole-alternative","privacy","rust","rustlang","tracker-blocking"],"latest_commit_sha":null,"homepage":"","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"agpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ron-png.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-27T22:24:06.000Z","updated_at":"2026-04-13T09:58:43.000Z","dependencies_parsed_at":null,"dependency_job_id":"ee16fb64-58e6-4769-b542-89e075558e3e","html_url":"https://github.com/ron-png/oxi-dns","commit_stats":null,"previous_names":["ron-png/oxi-hole","ron-png/oxi-dns"],"tags_count":151,"template":false,"template_full_name":null,"purl":"pkg:github/ron-png/oxi-dns","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ron-png%2Foxi-dns","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ron-png%2Foxi-dns/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ron-png%2Foxi-dns/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ron-png%2Foxi-dns/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ron-png","download_url":"https://codeload.github.com/ron-png/oxi-dns/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ron-png%2Foxi-dns/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31863499,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-15T15:24:51.572Z","status":"ssl_error","status_checked_at":"2026-04-15T15:24:39.138Z","response_time":63,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["adblock","adblocker","adguard","adguardhome","adguardhome-alternative","dns","dns-server","dns-sinkhole","doh","doq","dot","encrypted-dns","pi-hole","pihole-alternative","privacy","rust","rustlang","tracker-blocking"],"created_at":"2026-04-02T16:59:54.304Z","updated_at":"2026-04-15T23:01:02.552Z","avatar_url":"https://github.com/ron-png.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Oxi-DNS — DNS Ad Blocker \u0026 Sinkhole Written in Rust\n\nA fast, memory-safe DNS sinkhole that blocks ads, trackers, and malware at the network level. A modern alternative to Pi-hole and AdGuard Home, built from the ground up in Rust with encrypted DNS support.\n\nSupports plain DNS (UDP), DNS-over-TLS (DoT), DNS-over-HTTPS (DoH), and DNS-over-QUIC (DoQ). Ships as a single static binary with a built-in web dashboard — no dependencies, no containers required.\n\n\u003e [!NOTE]\n\u003e **This is a young project** - If you're looking for a more battle-tested solution, check out [AdGuard Home](https://github.com/AdguardTeam/AdGuardHome).\n\n\n\u003cimg width=\"934\" height=\"881\" alt=\"SCR-20260402-pajz\" src=\"https://github.com/user-attachments/assets/ba57edf1-308a-49bd-8f1c-0191a32d6939\" /\u003e\n\n\n\n## Quick Start\n\nGet Oxi-DNS running in under a minute:\n\n\n**Install (Linux, macOS, FreeBSD [amd64 only], OpenBSD [amd64 only])**\n```sh\nURL=\"https://raw.githubusercontent.com/ron-png/oxi-dns/main/scripts/install.sh\"; \\\n  (curl -fsSL \"$URL\" 2\u003e/dev/null || wget -qO- \"$URL\" 2\u003e/dev/null || \\\n   fetch -qo- \"$URL\" 2\u003e/dev/null || ftp -Vo - \"$URL\" 2\u003e/dev/null) | sh\n```\n\n**Or run with Docker**\n```bash\ndocker run -d --name oxi-dns \\\n  -p 53:53/udp -p 53:53/tcp \\\n  -p 853:853/tcp -p 853:853/udp \\\n  -p 443:443/tcp \\\n  -p 9853:9853 -p 9854:9854 \\\n  -v oxi-dns-data:/etc/oxi-dns \\\n  --restart unless-stopped \\\n  ghcr.io/ron-png/oxi-dns:latest\n```\n\nThen open the dashboard at **http://\u003chost\u003e:9853** (or **https://\u003chost\u003e:9854** with the auto-generated self-signed cert) and point a device's DNS at the server's IP. That's it — ads and trackers are blocked network-wide. The encrypted-DNS ports (DoT 853, DoH 443, DoQ 853/udp) are pre-published so you can just toggle them on in the dashboard later — drop `-p 443:443/tcp` if you already run a web server on the host. See [Installation](#installation) and [Configuration](#configuration) for details.\n\n## Table of Contents\n\n- [Why Oxi-DNS?](#why-oxi-dns)\n- [Features](#features)\n  - [DNS Protocol Support](#dns-protocol-support)\n  - [Network-Wide Ad \u0026 Tracker Blocking](#network-wide-ad--tracker-blocking)\n  - [DNS Blocking Modes](#dns-blocking-modes)\n  - [IPv6 Support](#ipv6-support)\n  - [Reliable Auto-Update](#reliable-auto-update)\n  - [Web Dashboard](#web-dashboard)\n  - [Query Logging \u0026 Statistics](#query-logging--statistics)\n- [Installation](#installation)\n  - [Install Script](#install-script)\n  - [Docker / Podman](#docker--podman)\n- [Configuration](#configuration)\n  - [\\[dns\\]](#dns)\n  - [\\[web\\]](#web)\n  - [\\[blocking\\]](#blocking)\n  - [\\[tls\\]](#tls)\n  - [\\[tls.acme\\]](#tlsacme)\n  - [\\[system\\]](#system)\n  - [\\[log\\]](#log)\n- [Command-line options](#command-line-options)\n- [Reconfigure](#reconfigure)\n- [HTTPS \u0026 Reverse Proxy](#https--reverse-proxy)\n- [Uninstall](#uninstall)\n- [API Reference](#api-reference)\n- [Contributing](#contributing)\n- [TODO/PLANS](#todoplans)\n\n## Why Oxi-DNS?\n\n- **Single binary, zero dependencies** — no Python, no PHP, no database server to maintain\n- **Encrypted DNS out of the box** — DoT, DoH, and DoQ alongside plain DNS\n- **Written in Rust** — memory-safe, no garbage collector, minimal resource usage\n- **Zero-downtime updates** — automatic self-updates with health checks and seamless binary replacement\n- **Runtime configuration** — change any setting from the web dashboard without restarting\n- **Root servers** — option to use the root servers as upstream DNS\n\n## Features\n\n### DNS Protocol Support\n\n- **Plain DNS** (UDP, port 53)\n- **DNS-over-TLS** (DoT, port 853)\n- **DNS-over-HTTPS** (DoH, port 443)\n- **DNS-over-QUIC** (DoQ, port 853/UDP)\n- Dual-stack IPv4/IPv6 listening on all protocols\n- Multiple listen addresses per protocol\n\n### Network-Wide Ad \u0026 Tracker Blocking\n\n- Block ads, trackers, and malware for every device on your network\n- Supports hosts-file, Adblock, and AdGuard filter syntax\n- Multiple blocklist sources (URLs or local files)\n- Custom blocked domains and allowlist\n- Automatic blocklist refresh on a configurable interval\n- One-click feature toggles:\n  - Ads, malware \u0026 trackers\n  - NSFW content\n  - Safe search enforcement (Google, Bing, DuckDuckGo)\n  - YouTube restricted mode\n  - Root server resolution (queries root DNS directly, bypassing third-party upstreams)\n\n### DNS Blocking Modes\n\nChoose how blocked queries are answered:\n\n| Mode | Behavior |\n|------|----------|\n| Default | 0.0.0.0 / :: (adblock-style) |\n| Refused | DNS REFUSED response |\n| NxDomain | NXDOMAIN (domain does not exist) |\n| NullIp | Always 0.0.0.0 / :: |\n| CustomIp | User-specified IPv4/IPv6 address |\n\nAll modes are changeable at runtime without restart.\n\n### IPv6 Support\n\n- AAAA response filtering toggle — disable to strip IPv6 records from DNS answers\n- Dual-stack listen addresses by default (`0.0.0.0` + `[::]`)\n- IPv6 root server fallback resolution\n\n### Reliable Auto-Update\n\nUpdates are designed to never leave you with a broken DNS server:\n\n1. **Download** the new binary for your platform\n2. **Health-check** — the new binary is started with `--health-check`, which verifies config loading, upstream resolution, and end-to-end DNS queries. A 30-second timeout kills stalled checks.\n3. **Replace** — on Linux, the old binary inode is unlinked (safe while running) and the new binary is written. A `.bak` backup is created.\n4. **Zero-downtime takeover** — the new process starts with `SO_REUSEPORT`, binding the same port alongside the old process. Once it writes a readiness file, the old process exits. DNS never goes down.\n\nIf any step fails, the old binary keeps running and the failure is reported in the dashboard. Checks run every 8 hours when auto-update is enabled. Manual updates from the dashboard use the same pipeline.\n\n### Web Dashboard\n\nAvailable at `http://\u003chost\u003e:9853`:\n\n- Real-time query stats (total, blocked, block rate)\n- Searchable query log with status/domain/client filters\n- Blocklist and allowlist management\n- Upstream DNS server configuration\n- Feature toggles and system settings\n- Update status and manual trigger\n- All changes take effect immediately — no restart needed\n\n### Query Logging \u0026 Statistics\n\n- SQLite-backed persistent query log (WAL mode) — configurable retention (default 7 days)\n- Separate persistent statistics database — hourly aggregates and top domains (default 90 days)\n- Search by domain, client IP, status, block source, feature, upstream\n- Historical stats API: time-series charts, top queried/blocked domains, summaries\n- Optional client IP anonymization\n\n## Installation\n\n### Install Script\n\nWorks on Linux, macOS, FreeBSD (amd64 only), and OpenBSD (amd64 only). The script auto-detects your init system (systemd, launchd, OpenRC, rc.d) and privilege tool (`sudo` or `doas`).\n\n```sh\nURL=\"https://raw.githubusercontent.com/ron-png/oxi-dns/main/scripts/install.sh\"; \\\n  (curl -fsSL \"$URL\" 2\u003e/dev/null || wget -qO- \"$URL\" 2\u003e/dev/null || \\\n   fetch -qo- \"$URL\" 2\u003e/dev/null || ftp -Vo - \"$URL\" 2\u003e/dev/null) | sh\n```\n\nInstalls the binary to `/opt/oxi-dns/`, config to `/etc/oxi-dns/config.toml`, and creates a service using the native init system.\n\n**Options** (pass via `sh -s -- \u003cflags\u003e`):\n\n| Flag | Description |\n|------|-------------|\n| `-c \u003cchannel\u003e` | Release channel: `stable` (default) or `development` (pre-releases). `beta` and `edge` are accepted as aliases for `development`. |\n| `-V \u003cversion\u003e` | Install a specific version (e.g. `v0.4.0.9-dev`). Skips version detection. |\n| `-r` | Reinstall — purge all files and install fresh |\n| `-U` | Update — download latest binary and restart service (preserves config) |\n| `-u` | Uninstall Oxi-DNS |\n| `-v` | Verbose output |\n| `-h` | Show help message |\n\n`-r`, `-u`, and `-U` are mutually exclusive.\n\nDuring a fresh install, the script interactively prompts for:\n- **Web dashboard port** (default 9853)\n- **DNS mode** (when systemd-resolved is detected): replace systemd-resolved or run alongside it on a different address/port\n\nExamples:\n```bash\n# Install latest stable\nURL=\"https://raw.githubusercontent.com/ron-png/oxi-dns/main/scripts/install.sh\"; \\\n  (curl -fsSL \"$URL\" 2\u003e/dev/null || wget -qO- \"$URL\" 2\u003e/dev/null || \\\n   fetch -qo- \"$URL\" 2\u003e/dev/null || ftp -Vo - \"$URL\" 2\u003e/dev/null) | sh\n\n# Install latest development (pre-release)\nURL=\"https://raw.githubusercontent.com/ron-png/oxi-dns/main/scripts/install.sh\"; \\\n  (curl -fsSL \"$URL\" 2\u003e/dev/null || wget -qO- \"$URL\" 2\u003e/dev/null || \\\n   fetch -qo- \"$URL\" 2\u003e/dev/null || ftp -Vo - \"$URL\" 2\u003e/dev/null) | sh -s -- -c development\n\n# Install a specific version\nURL=\"https://raw.githubusercontent.com/ron-png/oxi-dns/main/scripts/install.sh\"; \\\n  (curl -fsSL \"$URL\" 2\u003e/dev/null || wget -qO- \"$URL\" 2\u003e/dev/null || \\\n   fetch -qo- \"$URL\" 2\u003e/dev/null || ftp -Vo - \"$URL\" 2\u003e/dev/null) | sh -s -- -V v0.4.0.9-dev\n```\n\n### Docker / Podman\n\nImages are published to GHCR for `linux/amd64` and `linux/arm64`. The same commands work with `podman` — just substitute `podman` for `docker`.\n\n```bash\ndocker run -d \\\n  --name oxi-dns \\\n  --restart unless-stopped \\\n  -p 53:53/udp \\\n  -p 53:53/tcp \\\n  -p 853:853/tcp \\\n  -p 853:853/udp \\\n  -p 443:443/tcp \\\n  -p 9853:9853 \\\n  -p 9854:9854 \\\n  -v oxi-dns-data:/etc/oxi-dns \\\n  ghcr.io/ron-png/oxi-dns:latest\n```\n\nThe named volume `oxi-dns-data` persists `config.toml` along with the SQLite databases (`query_log.db`, `stats.db`, `auth.db`) that oxi-dns writes next to the config file. A single mount covers config, query logs, historical stats, and user accounts.\n\nOpen the dashboard at **http://\u003chost\u003e:9853** or **https://\u003chost\u003e:9854** (HTTPS uses a self-signed certificate by default).\n\n**Why all the ports up front?** A container's published ports are fixed at `docker run` time — there's no way to add them later from inside the container. To save you a recreate later, the recommended command pre-publishes every listener oxi-dns can bind: plain DNS (53), DoT (853/tcp), DoQ (853/udp), DoH (443), HTTP dashboard (9853), HTTPS dashboard (9854). The DoT/DoH/DoQ listeners are still **off in the config by default** — flip them on from the Network tab of the dashboard whenever you want, and the published ports will already be there. You can drop any `-p` line you don't need.\n\n**Conflict with port 443**: a lot of hosts already run a web server or reverse proxy on 443. If `docker run` fails with \"address already in use\" on 443, drop `-p 443:443/tcp`. You can keep DoT/DoQ on 853 even when DoH isn't published — and when you enable DoH later, it'll bind inside the container but won't be reachable from outside until you republish the port. Alternatively, bind the container's DoH to a different host port (e.g. `-p 8443:443/tcp`) and use your existing reverse proxy to forward `https://dns.example.com` to `localhost:8443` — see [HTTPS \u0026 Reverse Proxy](#https--reverse-proxy) for nginx and Caddy examples.\n\n**Conflict with port 53**: if the host already runs a DNS resolver, the `-p 53:53` bindings will fail with `address already in use` (or `failed to bind host port 0.0.0.0:53/tcp`). On most modern Linux distros — Ubuntu, Debian 11+, Fedora, RHEL 9+, openSUSE — the culprit is **`systemd-resolved`**: it binds `127.0.0.53:53` as a stub resolver, and Docker's `0.0.0.0:53` bind overlaps with it because `0.0.0.0` covers every interface including the loopback alias. (`dnsmasq`, `unbound`, `BIND`, or another container can also be the cause; check with `sudo ss -lunp 'sport = :53'` to see which.)\n\nThe cleanest fix on a `systemd-resolved` host is to disable just the **stub listener** while keeping `systemd-resolved` running for the host's own outgoing DNS. This is the same sequence the bare-metal install script uses (`src/reconfigure.rs`):\n\n```sh\n# 1. Tell resolved to stop binding 127.0.0.53:53\nsudo mkdir -p /etc/systemd/resolved.conf.d\nprintf '[Resolve]\\nDNSStubListener=no\\n' \\\n  | sudo tee /etc/systemd/resolved.conf.d/oxi-dns.conf\n\n# 2. Re-point /etc/resolv.conf away from the stub. Without this the host\n#    can't resolve names because /etc/resolv.conf currently points at\n#    127.0.0.53, which is about to disappear.\nsudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf\n\n# 3. Restart resolved so the new setting takes effect\nsudo systemctl restart systemd-resolved\n\n# 4. Confirm port 53 is now free\nsudo ss -lunp 'sport = :53'\nsudo ss -ltnp 'sport = :53'   # both should be empty\n```\n\nTo reverse it later (e.g. if you uninstall oxi-dns), undo all four steps:\n\n```sh\nsudo rm /etc/systemd/resolved.conf.d/oxi-dns.conf\nsudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf\nsudo systemctl restart systemd-resolved\n```\n\n**If you can't (or don't want to) disable the stub listener**, the alternatives are: bind oxi-dns's published port to a specific non-loopback host IP that doesn't overlap (`-p 192.168.1.10:53:53/udp`), publish DNS on a non-default host port like `-p 5300:53/udp -p 5300:53/tcp` (only useful for testing — most consumer devices can't query DNS on a non-standard port), or use `--network host` so the container shares the host's network namespace (Linux only — `--network host` is a no-op on Docker Desktop for Mac/Windows because the daemon runs inside a VM).\n\n**Don't change `dns.listen` from inside the dashboard in a container** — see [What works differently in a container](#what-works-differently-in-a-container) below.\n\n**Rootless Podman on Linux**: rootless Podman runs as your user, so its port-forwarding proxy can't bind to ports below 1024 by default. The recommended `podman run` command will fail with `permission denied` on ports 53, 443, and 853 — the kernel reserves them for root. There are two workable fixes:\n\n- **Lower the unprivileged-port floor** (the upstream-recommended path):\n  ```sh\n  echo 'net.ipv4.ip_unprivileged_port_start=53' | sudo tee /etc/sysctl.d/99-oxi-dns.conf\n  sudo sysctl --system\n  ```\n  This opens ports 53–1023 to every unprivileged user on the host, which covers 53, 443, and 853 in one shot. On a single-user / personal machine that's fine; on a shared multi-user server it's a small extra risk to weigh.\n- **Run rootful Podman** with `sudo podman run …`. You lose rootless user-namespace isolation, and the container/volume/image cache live in the system store (`/var/lib/containers`) instead of `~/.local/share/containers` — `podman ps` and `sudo podman ps` show *different* containers, which surprises most people once.\n\nIf you only want to kick the tyres without changing any sysctls, publish DNS on a high port instead — but **do not pick `5353`**: that's the mDNS port, and `avahi-daemon` already binds it on virtually every Linux desktop install (it's how `.local` hostname resolution and printer/Chromecast discovery work). `5300` is the conventional \"DNS but unprivileged\" port and is almost always free:\n```sh\npodman run -d --name oxi-dns \\\n  -p 5300:53/udp -p 5300:53/tcp \\\n  -p 9853:9853 -p 9854:9854 \\\n  -v oxi-dns-data:/etc/oxi-dns \\\n  --restart unless-stopped \\\n  ghcr.io/ron-png/oxi-dns:latest\n```\nTest with `dig @127.0.0.1 -p 5300 example.com`. This is fine for verifying the dashboard but not useful for serving real LAN clients, since most consumer devices have no way to query DNS on a non-default port. Production use needs port 53, which means one of the two fixes above.\n\n**Podman on macOS**: Podman runs inside a Linux VM on macOS, so port-forwarding goes through `gvproxy` and bind errors come from the macOS side, not from inside the container. `--network host` does *not* help on macOS — it shares the VM's network namespace, not your Mac's. If a port-bind fails, look at what's running on your Mac with `sudo lsof -iUDP:53 -iTCP:53 -P -n`. Common culprits are a stale `oxi-dns` container from a previous attempt (`podman rm -f oxi-dns`), a Homebrew DNS resolver (`brew services list`), or *System Settings → General → Sharing → Internet Sharing*.\n\nDocker Compose:\n\n```yaml\nservices:\n  oxi-dns:\n    image: ghcr.io/ron-png/oxi-dns:latest\n    container_name: oxi-dns\n    restart: unless-stopped\n    ports:\n      - \"53:53/udp\"\n      - \"53:53/tcp\"\n      - \"853:853/tcp\"   # DoT — listener is off by default; toggle on in dashboard\n      - \"853:853/udp\"   # DoQ — listener is off by default; toggle on in dashboard\n      - \"443:443/tcp\"   # DoH — listener is off by default; comment out if 443 is taken\n      - \"9853:9853\"     # Web dashboard (HTTP)\n      - \"9854:9854\"     # Web dashboard (HTTPS, self-signed by default)\n    volumes:\n      - oxi-dns-data:/etc/oxi-dns\n\nvolumes:\n  oxi-dns-data:\n```\n\nThe image ships with the project's default `config.toml` (Quad9 over DoT as upstreams, plain DNS on `:53`, dashboard on `:9853` HTTP / `:9854` HTTPS). On first start with an empty named volume, Docker copies that file into the volume so it persists across recreations — edit it via the dashboard, the API, or directly on the volume.\n\nThe image is otherwise stateless — every piece of state oxi-dns writes (`config.toml`, `query_log.db`, `stats.db`, `auth.db`, `cert.pem`, `key.pem`) lives under `/etc/oxi-dns/`, so a single named volume covers config, history, auth, and certs. `docker pull` + `docker rm` + `docker run` is non-destructive as long as the same volume is reattached.\n\n#### Updating the image\n\nThe in-process auto-updater (`system.auto_update` in `config.toml`, the **Update** button in *Advanced → System*) **does not work in containers and should stay off**. It rewrites `/usr/local/bin/oxi-dns`, which is image-layer storage and is discarded on every container recreation. The dashboard detects the container runtime (via `/.dockerenv` / `/run/.containerenv`) and replaces the in-place \"Update\" call-to-action with a \"View release →\" link to the new GitHub release.\n\nFor containerised installs, choose one of the following instead:\n\n| Tool | What it does | Best for |\n|---|---|---|\n| **Manual** (`docker pull \u0026\u0026 docker rm \u0026\u0026 docker run …`, or `docker compose pull \u0026\u0026 docker compose up -d`) | You decide when to upgrade. | Single hosts, anyone who wants a human in the loop. |\n| **[Watchtower](https://containrrr.dev/watchtower/)** | Polls the registry, pulls new images, recreates the container. Supports labels, schedules, and per-container opt-in. | Plain Docker hosts that want unattended updates. Pin a quiet hour with `--schedule` so an upgrade doesn't collide with an ACME renewal. |\n| **[Diun](https://crazymax.dev/diun/)** | Notifies you (Discord, ntfy, email, Gotify, Slack, …) when a new image digest is available, but doesn't pull anything. | \"Tell me, don't touch it\" workflows. |\n| **`podman auto-update`** | First-class Podman feature. Label the container `io.containers.autoupdate=registry`, generate a systemd unit with `podman generate systemd`, enable `podman-auto-update.timer`. | Podman + systemd hosts; the cleanest \"update built into the runtime\" option. |\n| **Renovate / Dependabot** | Opens a PR against your Compose file when the `ghcr.io/ron-png/oxi-dns:latest` digest changes. CI handles the rollout. | GitOps / IaC setups where Compose is checked into git. |\n| **Kubernetes image-update controllers** (Keel, Flux Image Automation, ArgoCD Image Updater) | Watch the registry and update the workload manifest. | Cluster deployments. |\n\nWhichever tool you use, the persistent volume at `/etc/oxi-dns/` carries everything across the upgrade — config, query log, stats, auth, certs.\n\n#### What works differently in a container\n\nA handful of features in oxi-dns assume a bare-metal init system (systemd, launchd, OpenRC) and a writable on-disk binary. Inside an image, those features either no-op, fall back to a coarser code path, or simply aren't reachable. The table below covers the gaps that aren't already mentioned in the cert section above.\n\n| Feature | Bare metal | In a container |\n|---|---|---|\n| **Listener port editing** (`dns.listen`, `web.listen`, `web.https_listen` ports + DoT/DoH/DoQ ports) | Editable from the dashboard's Network tab; the `--reconfigure` banner emits a `sudo … --reconfigure …` command that writes the new ports and restarts the service. | **Hidden in the dashboard.** A container's published ports are fixed at `docker run` time — changing the in-container listen port doesn't update the host's `-p HOST:CONTAINER` mapping, so it would silently break access. The dashboard detects the container runtime, hides every listener-port input, and shows an inline notice pointing here. To change a port, edit the `-p` lines in your `docker run` / Compose file on the host and recreate the container. |\n| **DoT / DoH / DoQ enable/disable toggles** | Toggling on binds the matching port in-process via the graceful restart. | Toggles are still visible. The recommended `docker run` / Compose pre-publishes 853/tcp, 853/udp, and 443/tcp so the toggles \"just work\" once enabled. Toggling produces a Docker-aware reconfigure banner — `docker exec oxi-dns oxi-dns --reconfigure dns.dot_listen=0.0.0.0:853 \u0026\u0026 docker restart oxi-dns` — that you run on the host. Substitute `podman` for `docker` if you use Podman. Expect ~1–3 s of DNS downtime during the restart. |\n| **`auto_redirect_https`, `trust_forwarded_proto`** | Live-applied via the API; non-port settings, no listener change. | Same — live-applied. Not affected by the container runtime. |\n| **`/opt/oxi-dns/oxi-dns` binary path** | Where the install script puts the binary. | Doesn't exist in the image. The binary is at `/usr/local/bin/oxi-dns` and is on `$PATH`, so any documented command can be invoked as `docker exec oxi-dns oxi-dns …`. |\n| **Install / uninstall scripts** (`scripts/install.sh`, `/opt/oxi-dns/uninstall.sh`) | Manage the systemd / launchd / OpenRC unit, drop the config in `/etc/oxi-dns/`, fetch the right binary, etc. | Not used. Install = `docker run`. Uninstall = `docker rm` (`docker volume rm oxi-dns-data` if you also want to wipe state). |\n| **Service restart** (`systemctl restart oxi-dns`, `launchctl unload/load`, `rc-service oxi-dns restart`) | Used by `--reconfigure`, `--update`, ACME install, and the manual cert upload flow. | Replaced by `docker restart oxi-dns` (or `docker compose restart oxi-dns`). Inside the image neither `systemctl`, `launchctl`, nor `rc-service` exist, so anything that internally tries to call them just falls through to the \"Could not detect init system. Please restart oxi-dns manually.\" path. |\n| **`systemd-resolved` coordination** (port-53 conflict handling in `--reconfigure`) | The bare-metal flow disables/re-enables the systemd-resolved stub listener when you bind/unbind port 53. | Not applicable inside the container's network namespace. If `systemd-resolved` is running on the **host** and holding port 53, that's a *host* problem — disable it on the host, change `dns.listen` to a different port, or run with `--network host`. |\n| **In-process zero-downtime restart** (SO_REUSEPORT takeover via `--takeover` + `--ready-file`) | New child binds the same port alongside the old process, becomes ready, parent exits — zero DNS downtime. | The takeover child is in the container's PID namespace under PID 1. When the parent exits, the kernel SIGKILLs the child too. Docker's restart policy then cold-starts the entrypoint, which loads the persisted config from the volume. Net result: ~1–3 s of DNS downtime instead of zero. |\n| **Built-in auto-update** (`system.auto_update`) | Downloads a new binary, health-checks it, swaps the inode, performs the SO_REUSEPORT takeover. | Doesn't apply. The dashboard now **hides the auto-update toggle and the in-place \"Update\" button** in container mode, replacing them with a link back to [Updating the image](#updating-the-image). The \"Check for Updates\" button stays so you can see whether a newer image tag is available, and the update-available banner already links to the GitHub release. |\n| **Health check** (`oxi-dns --health-check`) | Used by systemd `ExecStartPre`. | Still works as a binary command — wire it into Docker's `HEALTHCHECK` directive if you want orchestration to react: `HEALTHCHECK --interval=30s --timeout=35s CMD [\"oxi-dns\", \"--health-check\"]`. (The current image doesn't ship a `HEALTHCHECK` line; add one in your own override if you need it.) |\n\n#### Certificates in containers\n\nThe HTTPS dashboard cert workflows all *function* in the image, but the in-process zero-downtime restart that normally swaps a new cert into the running server falls back to a full container restart. Here's what to expect:\n\n| Cert source | Behaviour in container |\n|---|---|\n| **Self-signed (default)** | Generated in memory at startup, no disk, no restart. Works fully. The SANs cover `localhost`, `oxi-dns.local`, and the container's interface IPs — browsers will still warn on the host IP, same as bare metal. |\n| **Manual upload** (Advanced → Certificates) | The uploaded `cert.pem` / `key.pem` are written into `/etc/oxi-dns/` on the persistent volume and `config.toml` is updated to point at them. The server then tries an in-process graceful restart, but since oxi-dns runs as PID 1 inside the container the spawned takeover child gets SIGKILL'd as soon as the parent exits. The container terminates and Docker has to restart it — about 1–3 s of DNS downtime instead of zero. |\n| **ACME / Let's Encrypt** | Issuance works (DNS-01 only — no inbound port 80 needed; Cloudflare-API and manual confirmation modes are both supported). Install hits the same restart path as manual upload, and the auto-renewal loop will hard-restart the container roughly every 60 days when a 90-day Let's Encrypt cert enters its 30-day renewal window. |\n| **Built-in auto-update** | Don't enable. The updater rewrites `/usr/local/bin/oxi-dns`, which is image-layer storage and is lost on container recreation. |\n\n**Implications for your run command:**\n\n- **Always pass `--restart unless-stopped`** (or a Compose `restart: unless-stopped`) if you plan to use manual upload or ACME — without it, the container stays stopped after the first cert install or renewal.\n- **Keep the volume mounted at `/etc/oxi-dns`.** ACME writes to a hardcoded `/etc/oxi-dns/cert.pem` / `/etc/oxi-dns/key.pem` path; if you remap the config to a different directory, ACME will write the renewed cert into a location the running config no longer references.\n- For ACME, prefer the **Cloudflare** provider over manual mode — manual mode requires you to be at the dashboard to confirm each renewal, which doesn't pair well with an unattended container.\n\n## Configuration\n\nMost settings are configurable at runtime through the web dashboard. The config file is located at `/etc/oxi-dns/config.toml` and only a minimal subset is needed to get started:\n\n```toml\n[dns]\nlisten = \"0.0.0.0:53\"\nupstreams = [\n    \"tls://9.9.9.10:853\",\n    \"tls://149.112.112.10:853\",\n]\n\n[web]\nlisten = \"0.0.0.0:9853\"\n```\n\nEverything else is optional and defaults to sensible values. All listen fields accept either a single string or a list of strings (e.g. `\"0.0.0.0:53\"` or `[\"0.0.0.0:53\", \"[::]:53\"]`).\n\n### `[dns]`\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `listen` | string \\| list | `[\"0.0.0.0:53\", \"[::]:53\"]` | Addresses for plain DNS (UDP + TCP). |\n| `dot_listen` | string \\| list | *not set* | Addresses for DNS-over-TLS. Typically `\"0.0.0.0:853\"`. Requires a TLS certificate (auto-generates self-signed if none configured). |\n| `doh_listen` | string \\| list | *not set* | Addresses for DNS-over-HTTPS. Typically `\"0.0.0.0:443\"`. Uses HTTP/2 (h2 ALPN). |\n| `doq_listen` | string \\| list | *not set* | Addresses for DNS-over-QUIC. Typically `\"0.0.0.0:853\"` (UDP). Shares port number with DoT but on a different transport. |\n| `upstreams` | list | `[\"tls://9.9.9.9:853\", \"tls://1.1.1.1:853\"]` | Upstream DNS servers. Prefix with `udp://`, `tls://`, `https://`, or `quic://`. No prefix defaults to UDP. |\n| `timeout_ms` | integer | `5000` | Timeout for upstream queries in milliseconds. |\n| `cache_enabled` | bool | `true` | Enable DNS response caching. |\n\n### `[web]`\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `listen` | string \\| list | `[\"0.0.0.0:9853\", \"[::]:9853\"]` | Addresses for the HTTP web dashboard. |\n| `https_listen` | string \\| list | `[\"0.0.0.0:9854\", \"[::]:9854\"]` | Addresses for the HTTPS web dashboard. Added automatically on first run if missing. |\n| `auto_redirect_https` | bool | `false` | Redirect HTTP requests to HTTPS automatically. |\n| `trust_forwarded_proto` | bool | `false` | Trust the `X-Forwarded-Proto` header from a reverse proxy. **Only enable if oxi-dns is behind a trusted TLS-terminating proxy** — otherwise attackers can spoof the header. See [HTTPS \u0026 Reverse Proxy](#https--reverse-proxy). |\n\n### `[blocking]`\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `enabled` | bool | `true` | Master switch for ad/tracker blocking. |\n| `blocklists` | list | `[]` | URLs or file paths of blocklists to load (hosts-format or domain-list). |\n| `custom_blocked` | list | `[]` | Manually blocked domains. |\n| `allowlist` | list | `[]` | Domains that bypass blocking. |\n| `update_interval_minutes` | integer | `60` | How often to refresh blocklists. `0` disables auto-refresh. |\n| `enabled_features` | list | `[]` | Feature IDs to restore on restart (e.g. `\"safe_search\"`, `\"root_servers\"`). Managed by the dashboard. |\n| `blocking_mode` | table | `{ mode = \"Default\" }` | How blocked domains are answered. See blocking modes below. |\n\n**Blocking modes** (`blocking_mode.mode`):\n\n| Mode | Response | Description |\n|------|----------|-------------|\n| `Default` | `0.0.0.0` / `::` | Adblock-style null response. Hosts-file entries use the IP from the rule. |\n| `Refused` | REFUSED rcode | Tell the client the query was refused. |\n| `NxDomain` | NXDOMAIN rcode | Tell the client the domain doesn't exist. |\n| `NullIp` | `0.0.0.0` / `::` | Always respond with null IPs regardless of rule source. |\n| `CustomIp` | user-defined | Respond with custom IPs. Requires `value = { ipv4 = \"...\", ipv6 = \"...\" }`. |\n\nExample custom IP blocking mode:\n```toml\n[blocking.blocking_mode]\nmode = \"CustomIp\"\nvalue = { ipv4 = \"192.168.1.100\", ipv6 = \"::1\" }\n```\n\n### `[tls]`\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `cert_path` | string | *not set* | Path to a PEM certificate file. If omitted, a self-signed certificate is generated at startup covering `localhost`, `oxi-dns.local`, and all interface IPs. |\n| `key_path` | string | *not set* | Path to a PEM private key file. Must be set together with `cert_path`. |\n\n### `[tls.acme]`\n\nAutomatic certificate management via Let's Encrypt (or compatible CA).\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `enabled` | bool | `false` | Enable ACME certificate issuance and auto-renewal. |\n| `domain` | string | `\"\"` | Domain to issue the certificate for (e.g. `\"dns.example.com\"` or `\"*.example.com\"`). |\n| `email` | string | `\"\"` | Contact email for the ACME account. |\n| `provider` | string | `\"cloudflare\"` | DNS challenge provider. `\"cloudflare\"` for automatic DNS-01 via Cloudflare API, or `\"manual\"` to create TXT records yourself. |\n| `cloudflare_api_token` | string | `\"\"` | Cloudflare API token (required when `provider = \"cloudflare\"`). Must have DNS edit permissions for the zone. |\n| `use_staging` | bool | `false` | Use the Let's Encrypt staging environment for testing (avoids rate limits). |\n\n### `[system]`\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `auto_update` | bool | `false` | Automatically check for and apply updates. Updates are health-checked before applying. |\n| `ipv6_enabled` | bool | `true` | Include AAAA (IPv6) records in DNS responses. |\n| `release_channel` | string | `\"stable\"` | Release channel for updates. `\"stable\"` or `\"beta\"`. |\n\n### `[log]`\n\n| Key | Type | Default | Description |\n|-----|------|---------|-------------|\n| `query_log_retention_days` | integer | `7` | Days to keep query log entries before automatic cleanup. |\n| `stats_retention_days` | integer | `90` | Days to keep historical statistics. |\n| `anonymize_client_ip` | bool | `false` | Anonymize client IPs in the query log (e.g. `192.168.1.100` becomes `192.168.1.0`). |\n\n### Full example\n\nA fully-populated `config.toml` for reference (all values shown are defaults unless noted):\n\n```toml\n[dns]\nlisten = [\"0.0.0.0:53\", \"[::]:53\"]\n# dot_listen = [\"0.0.0.0:853\", \"[::]:853\"]\n# doh_listen = [\"0.0.0.0:443\", \"[::]:443\"]\n# doq_listen = [\"0.0.0.0:853\", \"[::]:853\"]\nupstreams = [\n    \"tls://9.9.9.9:853\",\n    \"tls://1.1.1.1:853\",\n]\ntimeout_ms = 5000\ncache_enabled = true\n\n[web]\nlisten = [\"0.0.0.0:9853\", \"[::]:9853\"]\nhttps_listen = [\"0.0.0.0:9854\", \"[::]:9854\"]\nauto_redirect_https = false\ntrust_forwarded_proto = false\n\n[blocking]\nenabled = true\nblocklists = []\ncustom_blocked = []\nallowlist = []\nupdate_interval_minutes = 60\nenabled_features = []\n\n[blocking.blocking_mode]\nmode = \"Default\"\n\n[tls]\n# cert_path = \"/etc/oxi-dns/cert.pem\"\n# key_path = \"/etc/oxi-dns/key.pem\"\n\n# [tls.acme]\n# enabled = true\n# domain = \"dns.example.com\"\n# email = \"you@example.com\"\n# provider = \"cloudflare\"\n# cloudflare_api_token = \"your-token-here\"\n\n[system]\nauto_update = false\nipv6_enabled = true\nrelease_channel = \"stable\"\n\n[log]\nquery_log_retention_days = 7\nstats_retention_days = 90\nanonymize_client_ip = false\n```\n\n## Command-line options\n\nThe `oxi-dns` binary is normally started by its systemd / launchd unit with\nno arguments, but every option it accepts is documented here for\ncompleteness. The packaged install path is `/opt/oxi-dns/oxi-dns`.\n\n```\noxi-dns [CONFIG_PATH] [OPTIONS]\n```\n\n| Option | Description |\n|---|---|\n| `CONFIG_PATH` (positional) | Path to a `config.toml` to load. Defaults to `/etc/oxi-dns/config.toml` when omitted. The first bare argument (one that doesn't start with `-` and isn't consumed by another flag) is treated as the config path. |\n| `--version`, `-V` | Print `oxi-dns \u003cversion\u003e` and exit. |\n| `--health-check` | Load the config, build the upstream client, issue a local DNS query end-to-end, and exit `0` on success. Intended for systemd `ExecStartPre` / graceful-restart health checks; a 30 s timeout aborts hung checks. |\n| `--reconfigure KEY=VALUE …` | Apply one or more network-listener changes and restart the service (requires root). See [Reconfigure](#reconfigure) below for the accepted keys. Any number of `key=value` pairs may follow the flag. |\n| `--takeover` | Marker used by the in-process graceful-restart flow to tell a freshly spawned child that it's taking over from a running parent. `SO_REUSEPORT` makes the hand-off seamless; the flag itself is a no-op beyond signalling intent. Not intended for manual use. |\n| `--ready-file PATH` | Write-path used together with `--takeover`: when the child has successfully bound its listeners, it touches `PATH` to tell the parent process it's ready to replace it. Not intended for manual use. |\n\nExamples:\n\n```bash\n# Print the version\noxi-dns --version\n\n# Load a non-default config path\noxi-dns /etc/oxi-dns/custom.toml\n\n# Run the health check (used by the graceful-restart flow)\noxi-dns --health-check\n```\n\n## Reconfigure\n\nChange network listen addresses from the command line (requires root):\n\n```bash\nsudo oxi-dns --reconfigure dns.listen=0.0.0.0:5353\nsudo oxi-dns --reconfigure web.listen=0.0.0.0:3000\nsudo oxi-dns --reconfigure dns.listen=0.0.0.0:53 dns.dot_listen=0.0.0.0:853\nsudo oxi-dns --reconfigure web.https_listen=0.0.0.0:9854 web.https_listen=[::]:9854\n```\n\nAccepted keys:\n\n| Key | Required | Clears with empty value? |\n|---|---|---|\n| `dns.listen` | yes | no |\n| `web.listen` | yes | no |\n| `web.https_listen` | yes | no |\n| `dns.dot_listen` | no | yes (`dns.dot_listen=`) |\n| `dns.doh_listen` | no | yes (`dns.doh_listen=`) |\n| `dns.doq_listen` | no | yes (`dns.doq_listen=`) |\n\nRepeat the same key to bind multiple addresses (e.g. IPv4 + IPv6). `--reconfigure` handles systemd-resolved automatically when switching to/from port 53. The dashboard's Network tab also generates these commands for you when you edit any listen field.\n\n`web.auto_redirect_https` and `web.trust_forwarded_proto` are **not** accepted by `--reconfigure` — they are web-editable via the Network tab and take effect through the in-process graceful restart (see below).\n\n## HTTPS \u0026 Reverse Proxy\n\nOxi-DNS generates a self-signed certificate at startup if no cert is configured, so the dashboard is always reachable over HTTPS (default port `9854`). Uploading a real certificate or issuing one via ACME replaces the self-signed one.\n\nSensitive endpoints — TLS cert upload, ACME provider tokens, login, setup, and password change — are **always blocked over plain HTTP**, regardless of configuration. The dashboard shows a warning banner and inline-replaces the affected forms when loaded over HTTP, with a one-click \"Switch to HTTPS\" button.\n\n### Configurable fields (Network tab)\n\n| Field | Default | Effect |\n|-------|---------|-------|\n| `web.https_listen` | `[\"0.0.0.0:9854\", \"[::]:9854\"]` | HTTPS listener for the dashboard. Always on — there is no enable/disable toggle. Ports are editable in the Network tab; saving emits a `sudo oxi-dns --reconfigure web.https_listen=…` command through the reconfig banner, same as DNS / HTTP / DoT / DoH / DoQ. |\n| `web.auto_redirect_https` | `false` | When enabled, all HTTP requests get a 308 redirect to HTTPS. When disabled, HTTP still serves the dashboard for non-sensitive endpoints. |\n| `web.trust_forwarded_proto` | `false` | Opt-in for reverse-proxied deployments (see below). ⚠ Security-critical. |\n\nWhen `auto_redirect_https` transitions from off to on, the dashboard shows a one-time banner recommending a password rotation (since the password may have been transmitted in plaintext before HTTPS enforcement). The banner clears automatically after a successful password change.\n\n### Running behind a reverse proxy\n\nA common deployment pattern is TLS termination at a reverse proxy:\n\n```\nclient ──HTTPS──▶ nginx/caddy/traefik ──HTTP──▶ oxi-dns\n```\n\nIn this setup, oxi-dns sees only plain HTTP from the proxy, so by default its HTTP-gating middleware blocks sensitive endpoints — including login — and you'd be locked out of your own dashboard.\n\nThe fix is the opt-in `web.trust_forwarded_proto` flag. When enabled, oxi-dns trusts the `X-Forwarded-Proto` header that every standard reverse proxy injects, treating `X-Forwarded-Proto: https` as equivalent to a direct HTTPS connection:\n\n```toml\n[web]\nlisten = [\"127.0.0.1:9853\"]          # bound to loopback, only reachable via proxy\nhttps_listen = [\"127.0.0.1:9854\"]    # optional — proxy can forward to HTTPS too\ntrust_forwarded_proto = true\n```\n\n**⚠ Only enable this flag if oxi-dns is *exclusively* reachable through a trusted reverse proxy.** If the HTTP listener is exposed to untrusted clients (e.g. bound to `0.0.0.0:9853` on an open network), an attacker can forge `X-Forwarded-Proto: https` and bypass every HTTPS-required check.\n\nThe middleware reads the **last** value of `X-Forwarded-Proto` if multiple are present, which is the authoritative value appended by the nearest trusted hop — a spoofed first value from an attacker before the proxy sees the request is ignored.\n\nA warning is logged whenever `trust_forwarded_proto` transitions from disabled to enabled as an audit trail.\n\n### Example: nginx\n\n```nginx\nserver {\n    listen 443 ssl http2;\n    server_name oxi-dns.example.com;\n\n    ssl_certificate     /etc/letsencrypt/live/oxi-dns.example.com/fullchain.pem;\n    ssl_certificate_key /etc/letsencrypt/live/oxi-dns.example.com/privkey.pem;\n\n    location / {\n        proxy_pass http://127.0.0.1:9853;\n        proxy_set_header Host $host;\n        proxy_set_header X-Forwarded-Proto $scheme;   # nginx sets https\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    }\n}\n```\n\nPair with `trust_forwarded_proto = true` in oxi-dns and bind `web.listen = [\"127.0.0.1:9853\"]` so the HTTP listener is not reachable from outside the host.\n\n### Example: Caddy\n\n```caddy\noxi-dns.example.com {\n    reverse_proxy 127.0.0.1:9853\n}\n```\n\nCaddy sets `X-Forwarded-Proto` automatically when using `reverse_proxy`.\n\n## Uninstall\n\nA standalone uninstall script is installed alongside the binary at `/opt/oxi-dns/uninstall.sh`. It works offline with no network access required:\n\n```bash\nsudo /opt/oxi-dns/uninstall.sh\n```\n\nAlternatively, via the install script:\n\n```bash\nURL=\"https://raw.githubusercontent.com/ron-png/oxi-dns/main/scripts/install.sh\"; \\\n  (curl -fsSL \"$URL\" 2\u003e/dev/null || wget -qO- \"$URL\" 2\u003e/dev/null || \\\n   fetch -qo- \"$URL\" 2\u003e/dev/null || ftp -Vo - \"$URL\" 2\u003e/dev/null) | sh -s -- -u\n```\n\n## API Reference\n\nAll endpoints are served on the web dashboard port (default `9853`). Authentication is via API token (`Authorization: Bearer \u003ctoken\u003e` header). Create tokens in the dashboard under **Advanced \u003e API Tokens**, or via the API itself.\n\n### Authentication\n\n```bash\n# Set your API token (create one in the dashboard, or via POST /api/tokens)\nexport OXI_TOKEN=\"your-api-token\"\n\n# Use it with any request\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/stats\n```\n\nSession cookies (from `/api/auth/login`) are also supported but API tokens are recommended for scripts and automation — they're scoped to specific permissions and can be revoked individually.\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `POST` | `/api/auth/login` | Login with username/password |\n| `POST` | `/api/auth/logout` | End session |\n| `GET` | `/api/auth/me` | Current user info and permissions |\n| `POST` | `/api/auth/change-password` | Change own password |\n| `POST` | `/api/auth/setup` | Initial admin account setup |\n\n### Stats \u0026 Queries\n\n```bash\n# Get current stats\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/stats\n\n# Get query log (with search and filtering)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" 'http://localhost:9853/api/logs?search=google.com\u0026status=blocked\u0026limit=50'\n\n# Get historical stats (time-series)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/stats/history\n\n# Top queried/blocked domains\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/stats/top-domains\n\n# Stats summary\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/stats/summary\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/stats` | Current query statistics |\n| `GET` | `/api/stats/history` | Historical time-series data |\n| `GET` | `/api/stats/top-domains` | Top queried and blocked domains |\n| `GET` | `/api/stats/summary` | Aggregated stats summary |\n| `GET` | `/api/queries` | Query log (supports `search`, `status`, `before_id`, `limit` params) |\n| `GET` | `/api/logs` | Query log (same as `/api/queries`) |\n| `GET` | `/api/logs/settings` | Log retention settings |\n| `POST` | `/api/logs/settings` | Update log/stats retention and anonymization |\n\n### Blocking\n\n```bash\n# Check blocking status\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/blocking\n\n# Disable blocking (e.g., for 5 minutes)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/blocking/disable\n\n# Re-enable blocking\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/blocking/enable\n\n# Get blocking mode\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/blocking/mode\n\n# Set blocking mode\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/blocking/mode \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"mode\": \"refused\"}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/blocking` | Current blocking status |\n| `POST` | `/api/blocking/enable` | Enable blocking |\n| `POST` | `/api/blocking/disable` | Disable blocking |\n| `GET` | `/api/blocking/mode` | Get blocking mode |\n| `POST` | `/api/blocking/mode` | Set mode (`default`, `refused`, `nxdomain`, `null_ip`, `custom_ip`) |\n\n### Domain Management\n\n```bash\n# List custom blocked domains\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/blocklist/custom\n\n# Block a domain\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/blocklist/add \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"domain\": \"example.com\"}'\n\n# Unblock a domain\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/blocklist/remove \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"domain\": \"example.com\"}'\n\n# List allowlisted domains\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/allowlist\n\n# Add to allowlist\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/allowlist/add \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"domain\": \"safe.example.com\"}'\n\n# Remove from allowlist\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/allowlist/remove \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"domain\": \"safe.example.com\"}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/blocklist/custom` | List custom blocked domains |\n| `POST` | `/api/blocklist/add` | Add domain to blocklist |\n| `POST` | `/api/blocklist/remove` | Remove domain from blocklist |\n| `GET` | `/api/allowlist` | List allowlisted domains |\n| `POST` | `/api/allowlist/add` | Add domain to allowlist |\n| `POST` | `/api/allowlist/remove` | Remove domain from allowlist |\n\n### Blocklist Sources\n\n```bash\n# List blocklist sources\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/blocklist-sources\n\n# Add a blocklist source\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/blocklist-source/add \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"url\": \"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts\"}'\n\n# Remove a blocklist source\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/blocklist-source/remove \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"url\": \"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts\"}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/blocklist-sources` | List all blocklist sources |\n| `POST` | `/api/blocklist-source/add` | Add blocklist URL |\n| `POST` | `/api/blocklist-source/remove` | Remove blocklist URL |\n| `GET` | `/api/blocklist-sources/refresh` | Trigger refresh (SSE stream) |\n| `GET` | `/api/blocklist-sources/last-refresh` | Last refresh timestamp |\n\n### Feature Toggles\n\n```bash\n# List all features\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/features\n\n# Enable root server resolution\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/features/root_servers \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"enabled\": true}'\n```\n\nAvailable feature IDs: `ads_malware`, `nsfw`, `safe_search`, `youtube_safe_search`, `root_servers`\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/features` | List all features with status |\n| `POST` | `/api/features/{id}` | Toggle feature on/off |\n\n### Upstream DNS\n\n```bash\n# List configured upstreams\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/upstreams\n\n# Add an upstream (supports udp://, tls://, https://, quic://)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/upstreams/add \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"upstream\": \"tls://1.1.1.1\"}'\n\n# Remove an upstream\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/upstreams/remove \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"upstream\": \"tls://1.1.1.1\"}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/upstreams` | List upstream DNS servers |\n| `POST` | `/api/upstreams/add` | Add upstream server |\n| `POST` | `/api/upstreams/remove` | Remove upstream server |\n\n### Cache\n\n```bash\n# Cache statistics\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/cache/stats\n\n# Flush the cache\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/cache/flush\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/cache/stats` | Cache hit/miss statistics |\n| `POST` | `/api/cache/flush` | Clear all cached DNS responses |\n\n### Network Configuration\n\n```bash\n# Get current network listen addresses and interfaces\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/system/network\n\n# Update optional protocol listeners (DoT, DoH, DoQ)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/network \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"dot_listen\": [\"0.0.0.0:853\", \"[::]:853\"]}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/system/network` | Current listen addresses and network interfaces |\n| `POST` | `/api/system/network` | Update DoT/DoH/DoQ listen addresses and the `auto_redirect_https` / `trust_forwarded_proto` toggles. Changes to `dns.listen`, `web.listen`, and `web.https_listen` must go through `sudo oxi-dns --reconfigure` (the dashboard generates the exact command in its reconfig banner). |\n\n### TLS Certificate Management\n\n```bash\n# Get current certificate info\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/system/tls\n\n# Upload PEM certificate\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/upload \\\n  -F 'cert_file=@cert.pem' -F 'key_file=@key.pem'\n\n# Upload PKCS12 certificate\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/upload \\\n  -F 'p12_file=@certificate.p12' -F 'password=mypassword'\n\n# Remove certificate (revert to self-signed)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/remove\n\n# Download certificate (requires password confirmation)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/download \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"password\": \"yourpassword\"}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/system/tls` | Current certificate info (subject, issuer, expiry) |\n| `POST` | `/api/system/tls/upload` | Upload PEM or PKCS12 certificate |\n| `POST` | `/api/system/tls/remove` | Revert to self-signed certificate |\n| `POST` | `/api/system/tls/download` | Export cert + key PEM (requires password) |\n\n### ACME / Let's Encrypt\n\n```bash\n# Issue a wildcard certificate via Cloudflare DNS\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/acme/issue \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"domain\": \"example.com\",\n    \"email\": \"admin@example.com\",\n    \"provider\": \"cloudflare\",\n    \"cloudflare_api_token\": \"your-cf-api-token\",\n    \"use_staging\": false\n  }'\n\n# Issue via manual DNS (you create the TXT record yourself)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/acme/issue \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"domain\": \"example.com\",\n    \"email\": \"admin@example.com\",\n    \"provider\": \"manual\",\n    \"use_staging\": false\n  }'\n\n# Check issuance progress\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/system/tls/acme/status\n\n# Confirm manual DNS challenge (after creating TXT record)\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/acme/confirm\n\n# Trigger manual renewal\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/acme/renew\n\n# Toggle auto-renewal\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/tls/acme/auto-renew \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"enabled\": true}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `POST` | `/api/system/tls/acme/issue` | Start certificate issuance (issues `example.com` + `*.example.com`) |\n| `GET` | `/api/system/tls/acme/status` | Issuance progress and ACME config |\n| `POST` | `/api/system/tls/acme/confirm` | Confirm manual DNS challenge |\n| `POST` | `/api/system/tls/acme/renew` | Trigger immediate renewal |\n| `POST` | `/api/system/tls/acme/auto-renew` | Enable/disable auto-renewal |\n\n### System\n\n```bash\n# Get version info\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/system/version\n\n# Check for updates\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/version/check\n\n# Perform update\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/update\n\n# Restart service\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/restart\n\n# Get/set auto-update\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/system/auto-update\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/auto-update \\\n  -H 'Content-Type: application/json' -d '{\"enabled\": true}'\n\n# Get/set IPv6\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/system/ipv6\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/ipv6 \\\n  -H 'Content-Type: application/json' -d '{\"enabled\": true}'\n\n# Get/set release channel\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/system/release-channel\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/system/release-channel \\\n  -H 'Content-Type: application/json' -d '{\"channel\": \"stable\"}'\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/system/version` | Version info |\n| `POST` | `/api/system/version/check` | Check for updates |\n| `POST` | `/api/system/update` | Start update |\n| `GET` | `/api/system/update/status` | Update progress |\n| `POST` | `/api/system/restart` | Restart the service |\n| `GET/POST` | `/api/system/auto-update` | Get/set auto-update |\n| `GET/POST` | `/api/system/ipv6` | Get/set IPv6 support |\n| `GET/POST` | `/api/system/release-channel` | Get/set release channel |\n| `GET/POST` | `/api/system/blocklist-interval` | Get/set blocklist refresh interval |\n\n### User Management\n\n```bash\n# List users\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/users\n\n# Create user\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/users \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"username\": \"viewer\", \"password\": \"pass123\", \"permissions\": [\"view_stats\", \"view_logs\"]}'\n\n# Update user permissions\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X PUT http://localhost:9853/api/users/2 \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"permissions\": [\"view_stats\", \"view_logs\", \"manage_features\"], \"active\": true}'\n\n# Reset user password\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/users/2/reset-password \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"new_password\": \"newpass123\"}'\n\n# Delete user\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X DELETE http://localhost:9853/api/users/2\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/users` | List all users |\n| `POST` | `/api/users` | Create user |\n| `PUT` | `/api/users/{id}` | Update user permissions/status |\n| `DELETE` | `/api/users/{id}` | Delete user |\n| `POST` | `/api/users/{id}/reset-password` | Reset user password |\n\n### API Tokens\n\n```bash\n# List tokens\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" http://localhost:9853/api/tokens\n\n# Create token\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X POST http://localhost:9853/api/tokens \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\": \"monitoring\", \"permissions\": [\"view_stats\"]}'\n\n# Revoke token\ncurl -H \"Authorization: Bearer $OXI_TOKEN\" -X DELETE http://localhost:9853/api/tokens/1\n```\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| `GET` | `/api/tokens` | List API tokens |\n| `POST` | `/api/tokens` | Create API token |\n| `DELETE` | `/api/tokens/{id}` | Revoke API token |\n\n### Permissions\n\nAvailable permissions for users and API tokens:\n\n| Permission | Description |\n|------------|-------------|\n| `view_stats` | View dashboard statistics |\n| `view_logs` | View query log |\n| `manage_features` | Toggle features (ad blocking, safe search, etc.) |\n| `manage_blocklists` | Add/remove blocklist sources |\n| `manage_allowlist` | Add/remove allowlisted domains |\n| `manage_upstreams` | Add/remove upstream DNS servers |\n| `manage_system` | Network config, TLS, updates, restart |\n| `manage_users` | Create/edit/delete users |\n| `manage_api_tokens` | Create/revoke API tokens |\n\n## Contributing\n\nBug reports, feature requests, and pull requests are welcome. Open an issue on GitHub.\n\n## TODO/PLANS\n\nPlease note that this list is not a promise, rather thoughts I might change my mind on in the future. Feel free to share your opinion and suggestions for the future of this project.\n\n### Goals for Version 1:\n- when query logs;\n  - Add a toggle to enable/disable query logging\n  - some users might want to keep query logging for a less than a day. (e.g. 12 hours)\n- When enabling DoH, the user should be able to define the https request path (feature)\n  - make sure that the path is not used by any other service on the same server.\n- if the user wants a different subdomain for DoH, DoT and DoQ, the user should be able to define it in the UI (feature)\n  - this should include the ability that the server automatically creates the needed certificates for the subdomain (if not already covered by wildcard certificate).\n  - if the subdomain doesn't exist in the DNS zone, the server should create a DNS A (and if enabled AAAA) record for the subdomain pointing to the server's IP address.\n    - this is only possible if the user has provided an API token for his authoritative DNS provider.\n- in addition, harden DoH, DoT and DoQ (feature) (pathing attacks, etc)\n- Verify that changing Settings in the UI (Like Port or listen address) works with the generated terminal commands. (ipv4 yes, ipv6 has to be fixed)\n- add a warning for cloudflare users, that the proxy should be disabled for oxi-dns to work properly. \n- the oxi-dns command should be able to signal to the web UI that the config has changed and the UI should reload the config. (feature)\n- test the container images\n\n### Goals for Version 2:\n- Make sure that system resources don't get overloaded so the server might crash. (ram and storage management)\n- Add a \"Test\" button for the upstream DNS servers (feature)\n- add oxi-dns cli commands. (adding certificate, rebooting the server) \n- Security enhancements\n  - DNSsec\n  - DNScrypt\n  - Rate limits for clients\n  - DDoS protection\n  - no DNAME, no EDNS0, sequential server tries within a single referral\n   step, and glueless-NS resolution reuses the bootstrap walker which itself  \n  only handles glued chains.\n- logging system errors\n- DHCP Server\n- redundancy feature. Dns server cluster\n- multiple subdomains using multiple filter configurations (feature)\n\n### Goals for Version 3:\n- More statistics, fancy graphs and more\n- Statistics need to be persistent\n- make the log entries clickable and show more information about the query\n- sort logs by ...\n- dns rewrites\n- ability to disable the Web UI (feature)\n  - including all the feaures that are not essential for the DNS server to run. (like statistics, logs, etc.)\n  - this should be done in a way that the server can still run without the Web UI.\n  - the Web UI should be able to be started and stopped independently of the DNS server.\n- ability to disable the API (feature)\n  - including all the feaures that are not essential for the DNS server to run. (like statistics, logs, etc.)\n  - this should be done in a way that the server can still run without the API.\n  - the API should be able to be started and stopped independently of the DNS server.\n- encrypted config download \n\n### Stuff that might be done\n- Look into RFC Compliance\n- Besides User Login and password, LDAP as well\n- Not only single entries, but complete Allowlist section\n- If my finances allow, a security audit of the code (option for Donations, maybe a donation button?)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fron-png%2Foxi-dns","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fron-png%2Foxi-dns","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fron-png%2Foxi-dns/lists"}