Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sheetdns DNS

A high-performance authoritative DNS server written in Python with a pluggable backend system. Store your zone data in Google Sheets, PostgreSQL, or any custom data source — edit your records and they go live automatically.

Table of Contents


Architecture

                         ┌──────────────────────────────────────────────────────┐
                         │                    DNS Server                        │
                         │                                                      │
  UDP/TCP queries ──────►│  ┌──────────────┐    ┌────────────┐    ┌──────────┐ │
                         │  │  UDP Workers  │───►│  Response   │───►│  Zone    │ │
                         │  │  (64 threads) │    │  LRU Cache  │    │  Cache   │ │
                         │  ├──────────────┤    │  (100k)     │    │  (dict)  │ │
                         │  │  TCP Listener │───►│             │    │          │ │
                         │  │  (threaded)   │    └────────────┘    └────┬─────┘ │
                         │  └──────────────┘                          │       │
                         └────────────────────────────────────────────┼───────┘
                                                                      │
                                                           Background │ refresh
                                                           (every 30s)│
                                                                      ▼
                                                           ┌─────────────────┐
                                                           │  Pluggable      │
                                                           │  Backend        │
                                                           │                 │
                                                           │  Google Sheets  │
                                                           │  PostgreSQL     │
                                                           │  Custom ...     │
                                                           └─────────────────┘
  1. Zone data is loaded from a pluggable backend into an in-memory ZoneCache (Python dict keyed by (fqdn, record_type) for O(1) lookups).
  2. A background thread re-syncs from the backend every refresh_interval seconds.
  3. A packed-response LRU cache sits in front of the resolver. Repeated queries for the same (qname, qtype) are answered by patching the two-byte query ID into an already-serialized DNS packet — no parsing or RR construction on the hot path.
  4. 64 UDP worker threads call recvfrom on a single shared socket (kernel-demultiplexed, standard pattern for high-throughput UDP servers on Linux).
  5. A TCP listener with per-connection threads handles TCP fallback for large responses.

The backend is never on the hot path. Every DNS lookup is answered from memory.


Prerequisites

  • Python 3.10+
  • For the Google Sheets backend: a GCP service-account key and a shared spreadsheet
  • For the PostgreSQL backend: a PostgreSQL 12+ server

Installation

git clone <repository-url>
cd sheetdns

# Create and activate a virtual environment (recommended)
python3 -m venv venv
source venv/bin/activate

# Install all dependencies (both backends)
pip install -r requirements.txt

# Or install only what you need:
pip install dnslib PyYAML                                   # core
pip install google-api-python-client google-auth            # + Sheets backend
pip install psycopg2-binary                                 # + Postgres backend

Configuration

Edit config.yaml to select your backend and tune the server.

The backend: section has a type key that selects which backend to use. All other keys in the section are passed to the backend class — each backend reads the keys it needs and ignores the rest.

Full config.yaml Reference

server:
  host: "0.0.0.0"          # Bind address
  port: 53                  # DNS port (53 requires root or CAP_NET_BIND_SERVICE)
  udp_workers: 64           # UDP recv threads (recommended: 2x CPU cores)
  tcp_workers: 32           # Max concurrent TCP connections
  tcp_timeout: 10           # TCP idle timeout (seconds)

cache:
  max_entries: 100000       # Response LRU cache size (~512 bytes/entry ≈ 50 MB)

backend:
  # "sheets", "postgres", or a custom "my_package.module.ClassName"
  type: sheets

  # How often (seconds) to re-pull zone data from the backend.
  refresh_interval: 30

  # ── Google Sheets options (type: sheets) ──
  spreadsheet_id: "YOUR_SPREADSHEET_ID_HERE"
  credentials_file: "credentials.json"

  # ── PostgreSQL options (type: postgres) ──
  # host: "localhost"
  # port: 5432
  # database: "dns"
  # user: "dns"
  # password: "secret"
  # dsn: "postgresql://dns:secret@localhost:5432/dns"   # alternative to individual keys

logging:
  level: "INFO"             # DEBUG, INFO, WARNING, ERROR, CRITICAL
  format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s"

Backends

Google Sheets

Store zone data in a Google Sheets spreadsheet. Each worksheet tab is one zone.

Google Cloud Setup

  1. Go to the Google Cloud Console.
  2. Create a new project (or select an existing one).
  3. Navigate to APIs & Services > Library and enable the Google Sheets API.
  4. Navigate to APIs & Services > Credentials.
  5. Click Create Credentials > Service account.
  6. Give it a name (e.g. dns-server) and click through the wizard.
  7. On the service account page, go to the Keys tab.
  8. Click Add Key > Create new key > JSON and save the file as credentials.json in the project directory.
  9. Copy the service account's email address (looks like dns-server@your-project.iam.gserviceaccount.com).
  10. Open your Google Sheets spreadsheet and click Share.
  11. Paste the service account email and give it Viewer access.

Config

backend:
  type: sheets
  refresh_interval: 30
  spreadsheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
  credentials_file: "credentials.json"

Spreadsheet Layout

Each worksheet (tab) represents one DNS zone. The tab title is the zone name.

┌─────────────────────────────────────────────────┐
│  Tab: "example.com"  │  Tab: "internal.corp"    │
└─────────────────────────────────────────────────┘

Row 1 of each worksheet must be a header row. The following column names are recognized (case-insensitive):

Column Aliases Required Description
Name hostname, host, record Yes Record name relative to the zone
Type record type, rtype Yes DNS record type
Value data, rdata, address, target, content Yes Record data
TTL — No Time to live in seconds (default: 300)
Priority prio, preference No Priority for MX and SRV records
Weight — No Weight for SRV records
Port — No Port for SRV records

Name Field

Value in Sheet Resolves To Description
@ example.com Zone apex
www www.example.com Subdomain
mail.us mail.us.example.com Multi-level subdomain
*.dev *.dev.example.com Wildcard
* *.example.com Zone-wide wildcard

Names are automatically converted to FQDNs using the zone (tab) name. Trailing dots are not required.

Record Types

Type Value Format Extra Columns Example Value
A IPv4 address — 192.168.1.1
AAAA IPv6 address — 2001:db8::1
CNAME Target hostname — cdn.cloudfront.net
MX Mail server hostname Priority mail.example.com
NS Nameserver hostname — ns1.example.com
TXT Text string — v=spf1 include:_spf.google.com ~all
SOA Space-separated fields — ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400
SRV Target hostname Priority, Weight, Port sip.example.com
PTR Target hostname — host1.example.com
CAA flags tag value — 0 issue letsencrypt.org

SOA value fields (space-separated): mname rname serial refresh retry expire minimum

Field Description Example
mname Primary nameserver ns1.example.com.
rname Admin email (@ replaced with .) admin.example.com.
serial Zone serial number (increment on every change) 2024010101
refresh Secondary refresh interval (seconds) 3600
retry Secondary retry interval (seconds) 900
expire Secondary expiry time (seconds) 604800
minimum Negative-cache TTL (seconds) 86400

Example Zone

A worksheet named example.com:

Name Type Value TTL Priority Weight Port
@ SOA ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400 86400
@ NS ns1.example.com 86400
@ NS ns2.example.com 86400
@ A 93.184.216.34 300
www A 93.184.216.34 300
www AAAA 2606:2800:220:1:248:1893:25c8:1946 300
@ MX mail.example.com 300 10
@ MX mail2.example.com 300 20
mail A 93.184.216.50 300
cdn CNAME d111111abcdef8.cloudfront.net 300
@ TXT v=spf1 include:_spf.google.com ~all 300
*.dev A 10.0.1.1 60
_sip._tcp SRV sip.example.com 300 10 60 5060
@ CAA 0 issue letsencrypt.org 86400

PostgreSQL

Store zone data in PostgreSQL for environments where a database is more appropriate than a spreadsheet.

Schema

The PostgreSQL backend uses two tables: zones and records. Create them automatically with:

python3 server.py --setup

Or run the SQL manually:

CREATE TABLE IF NOT EXISTS zones (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(255) NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS records (
    id       SERIAL PRIMARY KEY,
    zone_id  INTEGER      NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
    name     VARCHAR(255) NOT NULL,    -- @ for apex, www, *.dev, etc.
    type     VARCHAR(10)  NOT NULL,    -- A, AAAA, CNAME, MX, NS, TXT, SOA, SRV, PTR, CAA
    value    TEXT         NOT NULL,
    ttl      INTEGER      NOT NULL DEFAULT 300,
    priority INTEGER,                  -- MX / SRV priority
    weight   INTEGER,                  -- SRV weight
    port     INTEGER                   -- SRV port
);

CREATE INDEX IF NOT EXISTS idx_records_zone_id ON records(zone_id);
CREATE INDEX IF NOT EXISTS idx_records_lookup  ON records(zone_id, name, type);

The name column uses the same conventions as the Google Sheets backend: @ for the zone apex, www for a subdomain, *.dev for a wildcard, etc.

Setup

# 1. Create the database and user
sudo -u postgres psql <<'SQL'
CREATE USER dns WITH PASSWORD 'your_password';
CREATE DATABASE dns OWNER dns;
SQL

# 2. Configure config.yaml
#    (see Config section below)

# 3. Create tables
python3 server.py --setup

# 4. Populate zone data
psql -U dns -d dns <<'SQL'
INSERT INTO zones (name) VALUES ('example.com');

INSERT INTO records (zone_id, name, type, value, ttl) VALUES
  (1, '@',   'SOA', 'ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400', 86400),
  (1, '@',   'NS',  'ns1.example.com', 86400),
  (1, '@',   'NS',  'ns2.example.com', 86400),
  (1, '@',   'A',   '93.184.216.34',   300),
  (1, 'www', 'A',   '93.184.216.34',   300);

INSERT INTO records (zone_id, name, type, value, ttl, priority) VALUES
  (1, '@',   'MX',  'mail.example.com', 300, 10);
SQL

# 5. Start the server
sudo python3 server.py

Config

backend:
  type: postgres
  refresh_interval: 10

  # Option A: individual parameters
  host: "localhost"
  port: 5432
  database: "dns"
  user: "dns"
  password: "your_password"

  # Option B: connection string (takes precedence over individual params)
  # dsn: "postgresql://dns:your_password@localhost:5432/dns"

Managing Records

-- Add a zone
INSERT INTO zones (name) VALUES ('example.org');

-- Add records (zone_id from the zones table)
INSERT INTO records (zone_id, name, type, value, ttl)
VALUES (2, '@', 'A', '203.0.113.50', 300);

-- Update a record
UPDATE records SET value = '203.0.113.51' WHERE id = 42;

-- Delete a record
DELETE FROM records WHERE id = 42;

-- List all records for a zone
SELECT r.name, r.type, r.value, r.ttl, r.priority
FROM records r
JOIN zones z ON r.zone_id = z.id
WHERE z.name = 'example.com'
ORDER BY r.name, r.type;

Changes are picked up on the next background refresh cycle (refresh_interval seconds).


Writing a Custom Backend

You can use any data source — MySQL, Redis, a REST API, flat files — by creating a Python class that subclasses BaseBackend.

1. Create your backend module

Create a file (e.g. mysql_backend.py) in the project directory or anywhere on your Python path:

# mysql_backend.py
"""MySQL backend for sheetdns DNS."""

import logging
from backends import BaseBackend, VALID_RECORD_TYPES

logger = logging.getLogger("dns.mysql")


class MySQLBackend(BaseBackend):
    """
    Loads DNS zone data from a MySQL database.

    Uses the same schema as the PostgreSQL backend — a ``zones`` table
    and a ``records`` table.
    """

    SCHEMA = """\
CREATE TABLE IF NOT EXISTS zones (
    id   INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS records (
    id       INT AUTO_INCREMENT PRIMARY KEY,
    zone_id  INT          NOT NULL,
    name     VARCHAR(255) NOT NULL,
    type     VARCHAR(10)  NOT NULL,
    value    TEXT         NOT NULL,
    ttl      INT          NOT NULL DEFAULT 300,
    priority INT,
    weight   INT,
    port     INT,
    FOREIGN KEY (zone_id) REFERENCES zones(id) ON DELETE CASCADE
);

CREATE INDEX idx_records_zone_id ON records(zone_id);
CREATE INDEX idx_records_lookup  ON records(zone_id, name, type);
"""

    def __init__(self, config: dict):
        super().__init__(config)
        import mysql.connector
        self._mysql = mysql.connector

    def load_all_zones(self) -> dict[str, list[dict]]:
        conn = self._connect()
        try:
            cursor = conn.cursor()
            cursor.execute(
                "SELECT z.name, r.name, r.type, r.value, r.ttl, "
                "       r.priority, r.weight, r.port "
                "FROM records r "
                "JOIN zones z ON r.zone_id = z.id "
                "ORDER BY z.name"
            )

            zone_records: dict[str, list[dict]] = {}

            for (zone_name, name, rtype, value, ttl,
                 priority, weight, port) in cursor:
                rec: dict = {
                    "name": self.normalize_name(name, zone_name),
                    "type": rtype.upper().strip(),
                    "value": value.strip(),
                    "ttl":  str(ttl or 300),
                    "zone": zone_name.lower().rstrip("."),
                }
                if priority is not None:
                    rec["priority"] = str(priority)
                if weight is not None:
                    rec["weight"] = str(weight)
                if port is not None:
                    rec["port"] = str(port)

                zone_records.setdefault(zone_name, []).append(rec)

            return zone_records
        finally:
            conn.close()

    def setup(self):
        """Create the zones and records tables."""
        conn = self._connect()
        try:
            cursor = conn.cursor()
            for statement in self.SCHEMA.split(";"):
                statement = statement.strip()
                if statement:
                    cursor.execute(statement)
            conn.commit()
            logger.info("MySQL schema created successfully")
        finally:
            conn.close()

    def _connect(self):
        return self._mysql.connect(
            host=self.config.get("host", "localhost"),
            port=self.config.get("port", 3306),
            database=self.config.get("database", "dns"),
            user=self.config.get("user", "dns"),
            password=self.config.get("password", ""),
        )

2. Reference it in config.yaml

Use the full Python import path (module.ClassName) as the backend type:

backend:
  type: "mysql_backend.MySQLBackend"
  refresh_interval: 15
  host: "localhost"
  port: 3306
  database: "dns"
  user: "dns"
  password: "secret"

If your module is in a package (e.g. my_backends/mysql.py), use dotted notation:

backend:
  type: "my_backends.mysql.MySQLBackend"

3. Run setup and start

python3 server.py --setup          # calls MySQLBackend.setup()
sudo python3 server.py             # starts the DNS server

BaseBackend API Reference

Your class must subclass backends.BaseBackend and implement load_all_zones():

from backends import BaseBackend

class MyBackend(BaseBackend):

    def __init__(self, config: dict):
        """
        Called once at server startup.

        ``config`` is the full ``backend:`` section from config.yaml
        as a Python dict.  Read whatever keys you need (host, port,
        api_key, file_path, …).
        """
        super().__init__(config)

    def load_all_zones(self) -> dict[str, list[dict]]:
        """
        Called on startup and every ``refresh_interval`` seconds.

        Must return a dict mapping zone names to lists of record dicts.

        Each record dict MUST contain:
            name   – FQDN, lowercase, no trailing dot
                     (use self.normalize_name("www", "example.com")
                      → "www.example.com")
            type   – uppercase: A, AAAA, CNAME, MX, NS, TXT, SOA,
                     SRV, PTR, CAA
            value  – record data as a string
            ttl    – TTL in seconds as a string ("300")
            zone   – zone name, lowercase, no trailing dot

        Optional keys (for MX / SRV):
            priority, weight, port  – as strings
        """
        ...

    def setup(self):
        """
        Optional.  Called via ``python server.py --setup``.
        Use this to create tables, initialize schemas, etc.
        """
        ...

Key helper: self.normalize_name(name, zone) converts relative names (@, www, *.dev) to FQDNs. Always use it when building record dicts.


Running the Server

Direct

# Port 53 (requires root)
sudo python3 server.py

# Custom config path
sudo python3 server.py /etc/sheetdns/config.yaml

# High port for testing (no root needed — set port: 5053 in config.yaml)
python3 server.py

# One-time backend setup (e.g. create PostgreSQL tables)
python3 server.py --setup
python3 server.py --setup /path/to/config.yaml

The server loads all zone data on startup, then begins accepting DNS queries. Zone data is refreshed in the background every refresh_interval seconds.

Press Ctrl+C or send SIGTERM to shut down gracefully. The server logs response cache statistics on exit.

systemd

Create /etc/systemd/system/sheetdns-dns.service:

[Unit]
Description=sheetdns DNS Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/sheetdns
ExecStart=/opt/sheetdns/venv/bin/python3 /opt/sheetdns/server.py /opt/sheetdns/config.yaml
Restart=always
RestartSec=5

# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/sheetdns
PrivateTmp=yes

# Allow binding to port 53
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now sheetdns-dns
sudo journalctl -u sheetdns-dns -f

Docker

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py config.yaml ./
COPY backends/ backends/

EXPOSE 53/udp 53/tcp

CMD ["python3", "server.py"]
docker build -t sheetdns-dns .
docker run -d \
  --name sheetdns-dns \
  -p 53:53/udp \
  -p 53:53/tcp \
  -v $(pwd)/config.yaml:/app/config.yaml:ro \
  sheetdns-dns

Testing

Query the server using dig:

# A record (use @127.0.0.1 -p 5053 if using a high port)
dig @127.0.0.1 www.example.com A

# MX record
dig @127.0.0.1 example.com MX

# SOA record
dig @127.0.0.1 example.com SOA

# TXT record
dig @127.0.0.1 example.com TXT

# AAAA record
dig @127.0.0.1 www.example.com AAAA

# SRV record
dig @127.0.0.1 _sip._tcp.example.com SRV

# Wildcard test
dig @127.0.0.1 anything.dev.example.com A

# Test NXDOMAIN (name doesn't exist)
dig @127.0.0.1 nonexistent.example.com A

# Test REFUSED (zone not hosted here)
dig @127.0.0.1 google.com A

# TCP query
dig @127.0.0.1 example.com ANY +tcp

# Short output
dig @127.0.0.1 www.example.com A +short

Expected output for an A record query:

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 12345
;; flags: qr aa; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;www.example.com.		IN	A

;; ANSWER SECTION:
www.example.com.	300	IN	A	93.184.216.34

Key flags to verify:

  • aa — Authoritative Answer is set
  • status: NOERROR — query was successful
  • ANSWER: 1 — one record returned

Performance

Design

Layer Technique Impact
Socket 4 MB send/receive buffers Absorbs traffic bursts without drops
Concurrency 64 UDP threads on shared socket, kernel-demultiplexed Saturates multi-core under load
Response cache 100k-entry LRU of pre-serialized DNS packets Cache hits bypass all parsing, lookup, and serialization; only a 2-byte query-ID patch
Zone cache Python dict keyed by (fqdn, type) O(1) exact-match lookups
Wildcards Iterative suffix stripping with dict lookups O(label depth), typically 2-3 lookups
Zone reload Atomic swap of entire dict under lock Zero downtime; readers never see partial state
Cache invalidation Serial-number comparison on each query Response cache auto-clears on zone refresh, no stale data
Backend loading Single batch query (Sheets batchGet / Postgres JOIN) Minimizes round-trips

Tuning

udp_workers: Set to roughly 2x your CPU core count. More threads than cores adds context-switch overhead without improving throughput. For a 16-core server, 32-64 workers is a good starting point.

cache.max_entries: Size this for your expected cardinality of unique (qname, qtype) pairs. At ~512 bytes per entry, 100,000 entries uses ~50 MB. For a zone with 10,000 records queried across 5 record types, 50,000 entries gives excellent hit rates.

backend.refresh_interval: Balance between change propagation speed and backend load. For Google Sheets, keep it at 30s+ to stay within the API quota (300 req/min). For PostgreSQL, you can safely go as low as 5-10s.

Socket buffers: The server requests 4 MB send/receive buffers. If the kernel cap is lower (check sysctl net.core.rmem_max), increase it:

sudo sysctl -w net.core.rmem_max=4194304
sudo sysctl -w net.core.wmem_max=4194304

DNS Behavior

The server implements correct authoritative DNS semantics:

Scenario Response Code Authority Section Description
Record found NOERROR — Answer section contains matching records
Name exists, wrong type NOERROR SOA NODATA — name exists but not for the queried type
Name doesn't exist NXDOMAIN SOA Name not found in any authoritative zone
Zone not hosted REFUSED — Server is not authoritative for the queried zone
CNAME at name NOERROR — Returns the CNAME when querying for A/AAAA/etc. at a CNAME name
Wildcard match NOERROR — Answer uses the queried name (not *.zone) per RFC 4592

Multiple Zones

Host as many zones as you need. How you add them depends on your backend:

Google Sheets — add a new worksheet tab. The server discovers tabs automatically on each refresh.

PostgreSQL — insert a row into the zones table and add records referencing it.

INSERT INTO zones (name) VALUES ('newzone.org');
INSERT INTO records (zone_id, name, type, value, ttl)
VALUES (currval('zones_id_seq'), '@', 'A', '10.0.0.1', 300);

No config changes or restarts are needed. New zones are picked up on the next refresh cycle.


Reverse DNS (PTR)

For reverse DNS, create a zone named after the reverse zone (e.g. 10.in-addr.arpa):

Name Type Value TTL
@ SOA ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400 86400
@ NS ns1.example.com 86400
1.0.0 PTR gateway.example.com 300
2.0.0 PTR server1.example.com 300
3.0.0 PTR server2.example.com 300

Here 1.0.0 expands to 1.0.0.10.in-addr.arpa which is the PTR name for 10.0.0.1.

For IPv6, use the ip6.arpa zone with nibble-format names.


Troubleshooting

Server won't start — "Address already in use"

Another process is using port 53. Check with sudo lsof -i :53 or sudo ss -tlnp sport = :53. On Ubuntu/Debian, systemd-resolved often occupies port 53:

sudo systemctl disable --now systemd-resolved

Server won't start — "Permission denied"

Port 53 requires root. Either run with sudo, use setcap, or use a high port for testing:

sudo setcap cap_net_bind_service=+ep $(which python3)

"Cannot import backend module" on startup

The dependencies for your chosen backend aren't installed. Install them:

pip install google-api-python-client google-auth    # for type: sheets
pip install psycopg2-binary                         # for type: postgres

"Unknown backend type"

Check the backend.type value in config.yaml. Valid built-in values are sheets and postgres. For a custom backend, use the full import path: my_module.MyBackend.

"Failed to load zone data" on startup

For Google Sheets:

  • Verify credentials.json exists and is a valid service-account key file.
  • Verify the spreadsheet is shared with the service account email.
  • Verify spreadsheet_id matches the ID in the spreadsheet URL.
  • Verify the Google Sheets API is enabled in your GCP project.

For PostgreSQL:

  • Verify the database is reachable: psql -U dns -h localhost dns
  • Verify the tables exist: run python3 server.py --setup
  • Check connection parameters in config.yaml.

Records not updating

Changes propagate on the next background refresh cycle (default: 30 seconds). Check the logs for Zone cache updated messages. Set log level to DEBUG for verbose output.

REFUSED responses for a zone you're hosting

The zone name must exactly match the zone identifier (worksheet tab name or zones.name column). Comparison is case-insensitive, but make sure there are no trailing spaces or hidden characters.

Queries timing out

  • Check firewall rules — port 53 must be open for both UDP and TCP.
  • Verify the server is bound to the correct interface (0.0.0.0 for all interfaces).
  • Check dig @127.0.0.1 from the server itself to rule out network issues.

High memory usage

Reduce cache.max_entries in config.yaml. Each cache entry is ~512 bytes, so 100,000 entries ≈ 50 MB. For smaller deployments, 10,000 is sufficient.


Project Structure

sheetdns/
├── server.py            # DNS server: UDP/TCP listeners, zone cache,
│                        # response cache, resolver, entry point
├── backends/
│   ├── __init__.py      # BaseBackend class, backend loader, registry
│   ├── sheets.py        # Google Sheets backend
│   └── postgres.py      # PostgreSQL backend
├── config.yaml          # Server configuration
├── requirements.txt     # Python dependencies
└── credentials.json     # GCP service-account key (Sheets backend only)
Component File Description
BaseBackend backends/__init__.py Abstract base class for all backends
load_backend() backends/__init__.py Factory that instantiates the configured backend
SheetsBackend backends/sheets.py Google Sheets API client — batch-loads all worksheets
PostgresBackend backends/postgres.py PostgreSQL client — single JOIN query loads all zones
LRUCache server.py Thread-safe LRU cache for pre-serialized DNS response packets
ZoneCache server.py In-memory zone data store with O(1) dict lookups and wildcard fallback
DNSResolver server.py Query resolution engine — lookup, RR building, response caching
UDPWorker server.py Worker thread calling recvfrom on the shared UDP socket
TCPListener server.py TCP accept loop spawning per-connection TCPConnection threads
DNSServer server.py Top-level orchestrator — config, startup, shutdown, background refresh

Limitations

  • Python GIL: CPU-bound DNS processing is single-threaded despite multiple worker threads. For the highest possible throughput (millions of QPS), consider placing this behind a DNS load balancer or running multiple server processes.
  • No DNSSEC: The server does not sign responses. Use a DNSSEC-signing proxy if signatures are required.
  • No zone transfers (AXFR/IXFR): Secondary DNS servers cannot replicate from this server via zone transfers.
  • No recursion: This is a pure authoritative server. It does not resolve queries for zones it doesn't host — it returns REFUSED.
  • Eventual consistency: Record changes take up to refresh_interval seconds to go live.
  • Backend-specific limits: Google Sheets supports ~1.4M records (10M cells / 7 columns). PostgreSQL is limited only by available memory for the in-memory cache.

About

An LLM-written DNS server, Google Sheets as default backend.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages