Fully functional software-extensible DNS server written in C# targeting .NET 10. Ensure the .NET 10 SDK is installed before building or testing.
The project was conceived while working to reduce the cost of cloud datacentre "stamps", providing robust discovery services within a datacentre, while specifically removing the need for expensive load-balancer devices. The DNS Service would support software-defined/pluggable discovery of healthy hosts and services, and round-robin DNS services. Such that clients may re-resolve, and retry connectivity instead.
This software is licenced under MIT terms that permits reuse within proprietary software provided all copies of the licensed software include a copy of the MIT License terms and the copyright notice. See LICENSE.md
// clone the repo
>> cd $repo-root
>> git clone https://github.com/stephbu/csharp-dns-server
// check you can build the project
>> cd $repo-root/csharp-dns-server
>> dotnet build csharp-dns-server.sln
// check that the tests run
>> dotnet test csharp-dns-server.sln
// use DIG query appconfig'd local server
>> dig -p 5335 @127.0.0.1 www.google.com A
Note: The solution targets
net10.0; all commands above assume the .NET 10 SDK is available on your PATH.
- if you're running on Windows with Docker Tools installed, Docker uses the ICS SharedAccess service to provide DNS resolution for Docker containers - this listens on UDP:53, and will conflict with the DNS project. Either turn off the the service (
net stop SharedAccess), or change the UDP port.
This fork uses Jenkins for CI/CD (Jenkinsfile), not GitHub Actions.
Pipeline highlights:
- builds Docker images from
Dockerfile - runs the test suite inside the build/test container (
dotnet test) - publishes test/coverage artifacts (including Codecov upload)
- pushes images to Docker Hub for
master(production tag) anddev(-devtag)
Docker image:
mbeijer/dns-traefik(latestonmaster,latest-devondev)- https://hub.docker.com/r/mbeijer/dns-traefik
As written, the server has the following features:
- Pluggable Zone Resolver. Host one or more zones locally, and run your code to resolve names in that zone. Enables many complex scenarios such as:
- round-robin load-balancing. Distribute load and provide failover with a datacentre without expensive hardware.
- health-checks. While maintaining a list of machines in round-robin for a name, the code performs periodic healthchecks against the machines, if necessary removing machines that fail the health checks from rotation.
- Delegates all other DNS lookup to host machines default DNS server(s)
- Database-backed authoritative zones (CRUD via API + runtime resolution)
- Automatic setup of zones for docker instances running on a specific docker host via Traefik route discovery
- Authoritative secondary support primitives:
AXFR/IXFRover TCP andNOTIFYrequest/ack handling
The DNS server has a built-in ASP.NET Core web host with:
- Swagger/OpenAPI UI
- JWT-protected zone management API
- resolver dump endpoints (legacy compatibility)
- optional React SPA hosting from
Dns.Cli/wwwroot
The server ships with pluggable providers that publish authoritative data into SmartZoneResolver:
- CSV/AP provider (
Dns.ZoneProvider.AP.APZoneProvider) - file-watcher based CSV import (MachineFunction,StaticIP,MachineName) that groups addresses into A records per function. Seedocs/providers/AP_provider.md. - IPProbe provider (
Dns.ZoneProvider.IPProbe.IPProbeZoneProvider) - active probing of configured targets with health-based A record publication. Seedocs/providers/IPProbe_provider.md. - BIND provider (
Dns.ZoneProvider.Bind.BindZoneProvider) - file-watcher based BIND zone parser/validator ($ORIGIN,$TTL, SOA/NS/A/AAAA/CNAME/PTR/MX/TXT) that only publishes valid zones. Seedocs/providers/BIND_provider.md`. - Traefik provider (
Dns.ZoneProvider.Traefik.TraefikZoneProvider) - polls Traefik routes and emits A records for matching host rules in the configured zone. - Database provider (
Dns.ZoneProvider.DatabaseZoneProvider) - continuously loads enabled zones/records from the application database and serves them authoritatively.
Provider settings are configured per zone under server.zones[*].providerSettings with a $type discriminator:
ipprobeforIPProbeZoneProvidertraefikforTraefikZoneProviderfilewatcherfor file-backed providers (APZoneProvider,BindZoneProvider)- no provider settings required for
DatabaseZoneProvider
{
"server": {
"zones": [
{
"name": ".example.com",
"provider": "Dns.ZoneProvider.Bind.BindZoneProvider",
"providerSettings": {
"$type": "filewatcher",
"fileName": "/etc/dns/example.com.zone"
}
},
{
"name": ".internal",
"provider": "Dns.ZoneProvider.Traefik.TraefikZoneProvider",
"providerSettings": {
"$type": "traefik",
"traefikUrl": "https://traefik.local",
"username": "traefik-user",
"password": "traefik-password",
"dockerHostInternalIp": "10.0.0.10"
}
},
{
"provider": "Dns.ZoneProvider.DatabaseZoneProvider"
}
]
}
}Add the provider via appsettings.json using the same server.zones[] shape used by all providers:
{
"server": {
"zones": [
{
"name": ".example.com",
"provider": "Dns.ZoneProvider.Bind.BindZoneProvider",
"providerSettings": {
"$type": "filewatcher",
"fileName": "C:/zones/example.com.zone"
}
}
]
}
}The provider reads the file whenever it changes (a 10-second settlement window avoids partial writes), validates the directives/records, and only publishes A/AAAA data to SmartZoneResolver when the parse succeeds. All other record types are parsed/validated so that zone files failing to meet RFC expectations never poison the active zone.
AXFR and IXFR are only served over TCP when zone transfer is enabled and the caller IP is allowlisted.
{
"server": {
"dnsListener": {
"port": 53,
"tcpPort": 53
},
"zoneTransfer": {
"enabled": true,
"allowTransfersFrom": [
"ns2.afraid.org",
"198.51.100.0/24"
],
"notifySecondaries": [
"192.0.2.20:53"
],
"notifyPollIntervalSeconds": 5,
"injectedNsAddress": "192.0.2.53"
}
}
}allowTransfersFrom: required ACL for incoming AXFR/IXFR requests. Entries may be*, an exact IPv4/IPv6 address, an IPv4/IPv6 CIDR, or a DNS hostname. Hostnames authorize every resolved A and AAAA address and are refreshed from the system resolver every five minutes.notifySecondaries: optional list ofhost[:port]targets that receive outbound DNS NOTIFY whenever a zone serial changes. Hostnames expand to every resolved A and AAAA address and refresh every five minutes. Use[IPv6]:portwhen overriding the port for IPv6.injectedNsAddress: optional fallback address/target used only when the server auto-injects an apex NS for AXFR validity; IPv4 ->A, IPv6 ->AAAA, hostname ->CNAME.- UDP AXFR/IXFR requests are refused by design; use TCP transport.
This server can act as a secondary for another instance while continuing to serve locally configured zones. The secondary maintains a persistent, project-specific catalog subscription over the primary's DNS TCP port. A complete catalog is sent when the connection is established, again after every reconnect, and whenever any primary resolver publishes a zone change. New and changed zones are then transferred with standard TCP AXFR.
On the primary, enable transfers and allow every secondary source address. Hostname ACL entries authorize all of their resolved IPv4 and IPv6 addresses:
{
"server": {
"zoneTransfer": {
"enabled": true,
"allowTransfersFrom": [
"secondary-1.example.net",
"secondary-2.example.net"
]
}
}
}On each secondary, point secondarySync.master at the primary DNS TCP endpoint:
{
"server": {
"secondarySync": {
"enabled": true,
"master": "primary.example.net:53",
"reconnectDelaySeconds": 5,
"transferTimeoutSeconds": 30,
"transferRetryDelaySeconds": 5,
"maxConcurrentTransfers": 4,
"cacheFile": "/app/data/secondary-zones.json"
}
}
}- The primary accepts multiple simultaneous secondary catalog connections.
- Every connection and reconnect receives the full current zone list, so missed changes do not require manual recovery.
- After primary startup, catalog streams wait until every configured resolver has published its initial snapshot. This prevents reconnecting secondaries from treating a partially loaded primary as an authoritative deletion.
- Replicated zones take precedence over same-named local zones. Local zones not present on the primary continue to be served.
- When a zone disappears from a valid primary catalog, its replicated copy is removed and any same-named local zone becomes visible again.
cacheFileis optional. When configured on persistent storage, its last-known-good zones are loaded before reconnecting so a restarted secondary can keep answering during a primary outage.- Cached zones restore their complete record sets. Cache entries without records or an SOA are treated as incomplete and transferred again even when their serial matches the primary catalog.
- Losing the catalog connection never removes replicated zones. The in-memory resolver and
cacheFileretain their last-known-good snapshots until a complete, valid primary catalog explicitly removes a zone. - Once the primary is ready, its complete catalog is authoritative: zones omitted from that snapshot are removed from the secondary as the synchronization delta.
- Zone transfers run independently, up to
maxConcurrentTransfersat a time, so a stalled AXFR does not block catalog processing or other zones. Failed and timed-out transfers retry aftertransferRetryDelaySecondsuntil they succeed or disappear from the primary catalog. - Each successfully transferred zone is published to DNS and persisted immediately.
- The web admin zone overview lists zones from every active provider. Database zones are editable; synchronized, Traefik, BIND, probe, and other provider-managed zones are labeled by source and open in a read-only record view.
- The catalog connection carries 30-second heartbeat snapshots. A broken connection is retried after
reconnectDelaySeconds. - Both the catalog subscription and each AXFR are checked against the primary's
allowTransfersFromACL. - Publish the configured DNS port over TCP on both hosts. UDP alone is insufficient for replication.
The catalog and AXFR streams are not encrypted. Use a private network or VPN when zone contents must not traverse an untrusted network. See Secondary replication for protocol and operational details.
A React SPA is available under Dns.Spa/ and is designed to interface with the API in Dns.Cli.
Highlights:
- API client generation via NSwag (
Dns.Spa/nswag.json) - Redux Toolkit state management for async API calls (
authandzonesslices) - UI components and theming with MUI (
@mui/material) - Vite dev proxy for
/dns,/user,/dumptohttp://localhost:5000
- Start API:
cd Dns.Cli
dotnet run -- ./appsettings.json- Start SPA:
cd Dns.Spa
npm install
npm run generate:api
npm run devOptional:
- set
VITE_API_BASE_URLif your API runs on a different host/port. - regenerate the NSwag client whenever API contracts change.
Dns.Cli is configured with ASP.NET Core SPA middleware:
- Development:
UseSpa(...UseProxyToSpaDevelopmentServer("http://localhost:5173")) - Production: serves static files from
wwwrootTo host the React SPA via ASP.NET:
cd Dns.Spa
npm install
npm run generate:api
npm run build
cd ../Dns.Cli
dotnet run -- ./appsettings.jsonOn dotnet build/dotnet publish, Dns.Cli copies files from Dns.Spa/dist into Dns.Cli/wwwroot when dist exists.
Dockerfile uses a Node build stage to compile Dns.Spa and then copies dist into the .NET build stage so the final ASP.NET image serves the SPA in production mode.
Example docker-compose.yml for running the published Docker Hub image with DNS + API + Traefik routing:
services:
dns-service:
image: mbeijer/dns-traefik:latest
environment:
ASPNETCORE_URLS: "http://*:5000"
restart: always
volumes:
- ./appsettings.json:/app/appsettings.json
- ./data:/app/data
ports:
- "5000/tcp"
- "53:5335/udp"
- "53:5335/tcp"
dns:
- 8.8.8.8
networks:
- traefik_compose
labels:
- "traefik.http.routers.dns-service.rule=Host(`dns-service-docker-dns.local`) || Host(`dns-service-docker-dns.internal`) || Host(`dns.local`) || Host(`dns.internal`)"
- "traefik.http.routers.dns-service.entrypoints=web"
- "traefik.http.routers.dns-service-secured.rule=Host(`dns-service-docker-dns.local`) || Host(`dns-service-docker-dns.internal`) || Host(`dns.local`) || Host(`dns.internal`)"
- "traefik.http.routers.dns-service-secured.entrypoints=websecure"
- "traefik.http.routers.dns-service-secured.tls=true"
- "traefik.http.routers.dns-service-secured.tls.certresolver=myresolver"
- "traefik.http.services.dns-service.loadbalancer.server.port=5000"
networks:
traefik_compose:
external: true
name: traefik_composeRun it:
docker compose up -dNotes:
- DNS requests to host port
53are forwarded to container port5335(UDP/TCP). - The API/Swagger host runs on container port
5000. - Keep
./appsettings.jsonin sync with your desired providers/zones; it is mounted directly into/app/appsettings.json. - Image source: https://hub.docker.com/r/mbeijer/dns-traefik
You can convert a BIND zone file into a database-backed zone via the DNS API:
POST /dns/zones/import-bind
{
"fileName": "/path/to/example.com.zone",
"zoneSuffix": "example.com",
"enabled": true,
"replaceExistingRecords": true
}Behavior:
- Parses the file using the same BIND parser used by
BindZoneProvider. - Upserts the database zone by
suffix(creates if missing, updates if present). - Flattens each BIND RR datum into
zone_recordsrows.
To import and switch all currently active BIND providers at runtime:
POST /dns/zones/import-active-bind
{
"replaceExistingRecords": true,
"enableImportedZones": true
}This operation:
- Imports every currently active
BindZoneProviderzone file into the DB. - Disables those BIND providers in the running process after successful import.
- Product requirements describe the current roadmap, observability goals, and .NET maintenance plans.
- Project priorities & plan outline the P0/P1/P2 focus areas plus execution notes (DI migration, OpenTelemetry instrumentation).
- Task list captures the prioritized backlog that tracks to those priorities.
- Protocol references list the RFCs and supporting standards that guide implementation.
- AGENTS guide explains how automation/AI contributors should work within this repository.
Time-based constraints such as parental controls to block a site, e.g. Facebook. Logging of site usage e.g. company notifications
Two phases of testing was completed.
-
Verification that the bit-packing classes correctly added and removed bits in correct Endian order, complicated by network bitpacking in reverse order to Windows big-endian packing.
-
Protocol verification - that well known messages were correctly decoded and re-encoded using the bit-packing system.
Much time was spent using Netmon to capture real DNS challenges and verify that the C# DNS server responded appropriately.
The DNS protocol uses network byte order (big-endian) for all multi-byte values. The codebase is designed to work correctly on both little-endian (x86, x64, ARM) and big-endian systems:
- The
SwapEndian()extension methods inDns/Extensions.csconditionally swap bytes based onBitConverter.IsLittleEndian. - Semantic aliases
NetworkToHost()andHostToNetwork()provide clarity when converting DNS wire format. - Unit tests in
dnstest/EndianTests.csvalidate correct byte order handling.
No effort made to handle or respond to DNS-Sec challenges.
Pull Requests, Bug Reports, and Feature Requests are most welcome.
Suggested workflow for PRs is
- Make a fork of csharp-dns-server/master in your own repository.
- Create a branch in your own repo to entirely encapsulate all your proposed changes
- Make your changes, add documentation if you need it, markdown text preferred.
- Squash your commits into a single change (Find out how to squash here)
- Submit a PR, and put in comments anything that you think I'll need to help merge and evaluate the changes
If you are using automated tooling or AI agents, please review AGENTS.md to ensure you follow the approved scope and workflow.
All contributions must be licenced under the same MIT terms, do include a header file to that effect.