A basic but modular domain name server
This project explores domain-driven design (DDD) in Go, with a DNS application.
The application will contain a UDP transport (for anwering DNS questions) as well as a HTTP transport with endpoints for various purposes (CRUD operations against the DNS records store, enabling / disabling the UDP server, a health-check and status endpoint).
This allows working with a service layer composed of two (basic) elements, using a (records) store repository and a(n answering) DNS repository. The implmentations available are very simple, with the DNS server being based on github.com/miekg/dns
and the records store a simple (in-memory) Go map, optionally wrapped with a writer that stores this data to a file. These implementations satisfy said store repository, DDD style.
To simplify spawning these components, there is also a factory implementation, to simplify some of the initialization process; as well as strong support for CLI / container env / file-based configuration, making it seamless to spawn a new instance of the app and keep it configured as such.
Install dns
:
-
with Go:
go install github.com/zalgonoise/dns@latest
-
with Docker:
docker pull ghcr.io/zalgonoise/dns:latest
-
downloading from Releases: latest on GitHub
Note that depending on your deployment and use case, you may need to run
dns
with privileges (as root or with admin privileges) if, for example, you intend to have your DNS available on port53/udp
.
With dns
installed (or available), it can be executed using either CLI parameters, OS environment variables or with a configuration from file. Check each these sections for a list of available options and configurations.
Building - From the root of the repository, run:
go build -o dns .
This generates a binary you can execute directly.
Testing - From the root of the repository, run:
go test -v -timeout=0 ./...
Building - From the root of the repository, run:
bazel build //...
This builds a binary which you can use with Bazel (run, test, etc).
Testing - From the root of the repository, run:
bazel test //...
The app can be deployed to a container easily via the Dockerfile in the repository's root directory.
The Dockerfile will perform a multi-stage build with golang:alpine
fetching the dependencies and building the binary -- which is then copied to the final alpine:edge
container.
Building - From the root of the repository, run:
docker build -t dns:local .
To deploy the app (and also build+deploy) you can use the docker-compose.yaml
file where you can launch the app with a certain configuration (and also in an isolated container).
While the default file configures the container with a network_mode: host
setting, a setup that fits neatly in a home-based DNS deployment, you may prefer to set it up for an isolated network of containers -- for that you can comment-out the network_mode: host
line and uncomment the privileged
and ports
elements.
Executing - From the root of the repository, run:
docker compose up -d dns
A Record will contain information about a DNS record, holding its record type, domain name and IP address.
type Record struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Addr string `json:"address,omitempty"`
}
A RecordWithTarget will wrap a Record with a target domain name, used for updating a certain record.
type RecordWithTarget struct {
Record `json:"record,omitempty"`
Target string `json:"target,omitempty"`
}
A (DNS records) store repository defines the methods for accessing, registering and changing DNS records.
It exposes basic create, read, update, delete (CRUD) operations, as well as specific filter methods to satisfy all needed queries.
type Repository interface {
Create(context.Context, ...*Record) error
List(context.Context) ([]*Record, error)
FindByTypeAndDomain(context.Context, string, string) (*Record, error)
FilterByDomain(context.Context, string) ([]*Record, error)
FilterByDest(context.Context, string) ([]*Record, error)
Update(context.Context, string, *Record) error
Delete(context.Context, *Record) error
}
A basic implementation of a store repository with a Go map grouping a set of record types to domain names to IP addresses. The access to the data is protected with a sync.RWMutex
.
The reason for the order of the elements in the map (record types > domain names > IP addresses) is to favor DNS queries, that will ask for a certain record type and domain name. This is the most effective way to group this data for these kinds of queries; while sacrificing write operations with longer times.
type MemoryStore struct {
// maps a set of record types to domain names to IPs
Records map[string]map[string]string
mtx sync.RWMutex
}
A wrapper for memmap
, this implementation will flush all records to a file in either JSON or YAML format, when any change is done.
As the struct implies, it will simply leverage the memmap
implementation and call its methods (while writing the store contents to a file on any type of mutation).
type FileStore struct {
Path string `json:"path,omitempty" yaml:"path,omitempty"`
store store.Repository
enc encoder.EncodeDecoder
mtx sync.RWMutex
}
A DNS (answering service) repository will define the methods for replying to DNS questions for both stored domains as well as to fallback to a secondary DNS in case no records are found for a certain domain.
For the moment, as there are no other DNS implementations (the server logic), it strictly follows a model based on miekg/dns
type Repository interface {
Answer(*store.Record, *dns.Msg)
Fallback(*store.Record, *dns.Msg)
}
While its Answer method will simply pass the record type, domain name and IP address from the input *store.Record
into the input *dns.Msg.Answer
as a *dns.RR
; the repository also handles a fallback scenario where the record is not found in the record store (for instance).
That is where its Fallback method kicks in, spawning a DNS client to forward the same question to each of the configured fallback DNS, until a valid answer is retrieved. Then, it is appended to *dns.Msg.Answer
as in the Answer method, and the function ends.
type DNSCore struct {
fallbackDNS []string
}
A Health repository will define methods for a health-check / status report on the application's running services.
This involves basic tests against the services to provide the user with a summary of the current status of the application.
type Repository interface {
Store(int, time.Duration) *StoreReport
DNS(address string, fallback string, records *store.Record) *DNSReport
HTTP(port int) *HTTPReport
Merge(*StoreReport, *DNSReport, *HTTPReport) *Report
}
While the service layer actually performs the calls to feed its methods, this implementation will generate a quick and simple report (with sane defaults) providing context on the status of the application.
Each of the probed services will be awarded a health.Status
value, which will be determined from the input metadata.
When all three reports are generated, they can be fed into the Merge
method which will merge all the data and also determining an overall status of the app.
type shealth struct{}
The service layer is what glues the different repositories for the different services, and allows them to interact with eachother.
Its interfaces are sharded into different scopes, with a general Service
interface containing all functionalities. This allows contiguring (for instance), transport elements one level above with a more limited list of method it can access. This is evident in the UDP transport, where the UDP server only contains a service.Answering
interface as one of its elements.
A service instance is spawned with a DNS Repository, a Store Repository, a Health Repository and a Config.
type Service interface {
StoreService
DNSService
HealthService
}
type StoreService interface {
AddRecord(ctx context.Context, r *store.Record) error
AddRecords(ctx context.Context, rs ...*store.Record) error
ListRecords(ctx context.Context) ([]*store.Record, error)
GetRecordByTypeAndDomain(ctx context.Context, rtype, domain string) (*store.Record, error)
GetRecordByAddress(ctx context.Context, address string) ([]*store.Record, error)
UpdateRecord(ctx context.Context, domain string, r *store.Record) error
DeleteRecord(ctx context.Context, r *store.Record) error
}
type DNSService interface {
AnswerDNS(r *store.Record, m *dnsr.Msg)
}
type HealthService interface {
StoreHealth() *health.StoreReport
DNSHealth() *health.DNSReport
HTTPHealth() *health.HTTPReport
Health() *health.Report
}
type StoreWithHealth interface {
StoreService
HealthService
}
type Answering interface {
GetRecordByTypeAndDomain(context.Context, string, string) (*store.Record, error)
AnswerDNS(*store.Record, *dnsr.Msg)
}
The service layer exposes middleware too, which are none other than wrappers for the Service interface, to perform a certain set of actions before or after (or both) to Service method calls.
For the moment, a logger middleware is available in its own folder
The app works with two transport types, UDP for answering DNS questions and HTTP to expose certain endpoints, providing users with controls over the DNS records store, the DNS server, and health checks.
The UDP transport will listen on DNS queries, while interacting with the service, with its service.Answering
interface.
type Server interface {
Start() error
Stop() error
Running() bool
}
While there is only one implementation of udp.Server
with miekg/dns, there is room to expand the app with a new implementation of the server.
This implementation leverages the miekg/dns
library to serve as a DNS server. It's also configured with a service.Answering
interface to interact with the DNS records store.
type udps struct {
on bool
ans service.Answering
conf *udp.DNS
srv *dns.Server
err error
}
HTTP will expose endpoints to provide users with access to the DNS records store, the DNS server and health-checks.
type Server interface {
Start() error
Stop() error
}
type HTTPAPI interface {
StartDNS(w http.ResponseWriter, r *http.Request)
StopDNS(w http.ResponseWriter, r *http.Request)
ReloadDNS(w http.ResponseWriter, r *http.Request)
AddRecord(w http.ResponseWriter, r *http.Request)
ListRecords(w http.ResponseWriter, r *http.Request)
GetRecordByDomain(w http.ResponseWriter, r *http.Request)
GetRecordByAddress(w http.ResponseWriter, r *http.Request)
UpdateRecord(w http.ResponseWriter, r *http.Request)
DeleteRecord(w http.ResponseWriter, r *http.Request)
Health(w http.ResponseWriter, r *http.Request)
}
The endpoints are configured with a standard-library HTTP server and muxer. Similarly, there isn't much complexity with the endpoints themselves, mostly based on GET / POST HTTP requests (without actually specifying any data in the URL path or parameters, at most as POST data).
The endpoints can be implemented with any HTTP library, provided they can satisfy the HTTPAPI
interface (and the endpoints themselves are accessible).
Below is a list of all endpoints and their characteristics:
Endpoint | Method | Action | Description | Post Data |
---|---|---|---|---|
/dns/start |
GET |
StartDNS |
Starts the DNS server | N/A |
/dns/stop |
GET |
StopDNS |
Stops the DNS server | N/A |
/dns/reload |
GET |
ReloadDNS |
Stops and then starts the DNS server | N/A |
/records/add |
POST |
AddRecord |
Adds a new entry to the DNS records store | {"name":"not.a.dom.ain","type":"A","address":"192.168.0.10"} |
/records |
GET |
ListRecords |
Lists all DNS records in the store | N/A |
/records/getAddress |
POST |
GetRecordByDomain |
Gets the IP Address of a record, filtered by domain name and by record type | {"name":"not.a.dom.ain","type":"A"} |
/records/getDomains |
POST |
GetRecordByAddress |
Gets a list of record types and associated domains, filtered by IP address | {"address":"192.168.0.10"} |
/records/update |
POST |
UpdateRecord |
Updates a certain record by targetting its domain name | {"target":"not.a.dom.ain","record":{"name":"really.not.a.dom.ain","type":"A","address":"192.168.0.10"}} |
/records/delete |
POST |
DeleteRecord |
Removes a record from the store, by targetting its domain name and record type | {"name":"really.not.a.dom.ain","type":"A"} |
/health |
GET |
DeleteRecord |
Generates a health-check / status report on the app's services | N/A |
To make it easier to spawn each of these, be it a repository, service or anything else, there is a factory
package available.
This package will simply do all the manual configuration work and spit out what you really need:
func StoreRepository(rtype string, path string) store.Repository
func DNSRepository(rtype string, fallbackDNS ...string) dns.Repository
func HealthRepository(rtype string) health.Repository
func Service(dnsRepo dns.Repository, storeRepo store.Repository, healthRepo health.Repository, conf *config.Config) service.Service
func UDPServer(stype, address, prefix, proto string, svc service.Service) udp.Server
func Server(dnstype, dnsAddress, dnsPrefix, dnsProto string, httpPort int, svc service.Service) (httpapi.Server, udp.Server)
func From(conf *config.Config) httpapi.Server
While most of these are granular enough to compose your configuration along the way, it is worth underlining that the most streamlined option is to leverage the From(*config.Config) httpapi.Server
function, and in one-shot set up the app (from a configuration, even if default).
The command-line interface for this app is set up in cmd
; who will also manage the configuration structures for the app.
For each service or major feature, there will be a dedicated data structure used to configure it.
Every single configuration struct will need to satisfy the ConfigOption
interface, which only contains one method which applies said configuration to a pointer to a Config:
type ConfigOption interface {
Apply(*Config)
}
The Config itself will be composed of many modules as pointed out above:
type Config struct {
DNS *DNSConfig `json:"dns,omitempty" yaml:"dns,omitempty"`
Store *StoreConfig `json:"store,omitempty" yaml:"store,omitempty"`
HTTP *HTTPConfig `json:"http,omitempty" yaml:"http,omitempty"`
Logger *LoggerConfig `json:"logger,omitempty" yaml:"logger,omitempty"`
Autostart *AutostartConfig `json:"autostart,omitempty" yaml:"autostart,omitempty"`
Health *HealthConfig `json:"health,omitempty" yaml:"health,omitempty"`
Type string `json:"type,omitempty" yaml:"type,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
}
type DNSConfig struct {
Type string `json:"type,omitempty" yaml:"type,omitempty"`
FallbackDNS string `json:"fallback,omitempty" yaml:"fallback,omitempty"`
Address string `json:"address,omitempty" yaml:"address,omitempty"`
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
Proto string `json:"proto,omitempty" yaml:"proto,omitempty"`
}
type StoreConfig struct {
Type string `json:"type,omitempty" yaml:"type,omitempty"`
Path string `json:"path,omitempty" yaml:"path,omitempty"`
}
type HTTPConfig struct {
Port int `json:"port,omitempty" yaml:"port,omitempty"`
}
type LoggerConfig struct {
Path string `json:"path,omitempty" yaml:"path,omitempty"`
Type string `json:"type,omitempty" yaml:"type,omitempty"`
}
type AutostartConfig struct {
DNS bool `json:"dns,omitempty" yaml:"dns,omitempty"`
}
type HealthConfig struct {
Type string `json:"type,omitempty" yaml:"type,omitempty"`
}
As it is ensured that using the config.New()
or config.Default()
functions will correctly initialize a Config (even ready for usage, if you wanted an in-memory store), the configuration struct will simply work with the target field. Any validation for the input is done on a separate function. Take as an example config.HTTPPort(p int) ConfigOption
:
func HTTPPort(p int) ConfigOption {
if p > 65535 || p == 0 {
return nil
}
return &httpPort{
p: p,
}
}
type httpPort struct {
p int
}
// Apply implements the ConfigOption interface
func (h *httpPort) Apply(c *Config) {
c.HTTP.Port = h.p
}
Below is a list of all CLI parameters (flags) you can pass when starting the app:
Flag | Type | Default | Description |
---|---|---|---|
-dns-addr |
string |
:53 |
the address to listen to for DNS queries |
-dns-fallback |
string |
use a secondary DNS to parse unsuccessful queries | |
-dns-prefix |
string |
. |
the prefix for DNS queries / answers. Usually it's a period (.) |
-dns-proto |
string |
udp |
the protocol for the DNS server |
-dns-type |
string |
miekgdns |
use a specific domain-name server implementation |
-file |
string |
load a config from a file | |
-health-type |
string |
simplehealth |
the type of health / status report |
-http-port |
int |
8080 |
port to use for the HTTP API, defaults to :8080 |
-log-path |
string |
the log file's path, to register events | |
-log-type |
string |
text |
the type of formatter to use for the logger (text, json, yaml) |
-start-dns |
bool |
true |
automatically start the DNS server |
-store-path |
string |
the record store file path, if stored to a file | |
-store-type |
string |
memmap |
the record store implementation to use (memmap, yamlfile, jsonfile) |
Below is a list of all OS environment variables you can set before starting the app:
Variable name | Type | Description |
---|---|---|
DNS_ADDRESS |
string |
the address to listen to for DNS queries |
DNS_FALLBACK |
string |
use a secondary DNS to parse unsuccessful queries |
DNS_PREFIX |
string |
the prefix for DNS queries / answers. Usually it's a period (.) |
DNS_PROTO |
string |
the protocol for the DNS server |
DNS_TYPE |
string |
use a specific domain-name server implementation |
DNS_CONFIG_PATH |
string |
load a config from a file |
DNS_HEALTH_TYPE |
string |
the type of health / status report |
DNS_API_PORT |
int |
port to use for the HTTP API, defaults to :8080 |
DNS_LOGGER_PATH |
string |
the log file's path, to register events |
DNS_LOGGER_TYPE |
string |
the type of formatter to use for the logger (text, json, yaml) |
DNS_AUTOSTART |
string |
automatically start the DNS server |
DNS_STORE_PATH |
string |
the record store file path, if stored to a file |
DNS_STORE_TYPE |
string |
the record store implementation to use (memmap, yamlfile, jsonfile) |
Below is the content of an example configuration file, in YAML format:
dns:
type: miekgdns
fallback: 1.1.1.1
address: :53
prefix: .
proto: udp
store:
type: yamlfile
path: /tmp/dns/dns.list
http:
port: 8080
logger:
type: text
path: /tmp/dns/dns.log
autostart:
dns: true
health:
type: simplehealth
type: yaml
path: /tmp/dns/dns.conf