Pluggable authoritative DNS server. Entries can be added & removed from an HTTP REST API.
Constellation is a small authoritative server that lets you manage DNS entries from an HTTP REST API, in a generic way. It can be plugged to your existing infrastructure to manage DNS records for users of your service, eg. to configure outbound email records that cannot be easily wildcarded in a traditional DNS server (DKIM, DMARC, SPF records).
DNS entries are stored in Redis. The DNS database can thus be easily modified and dumped for backup purposes.
Tested at Rust version: rustc 1.62.0 (a8314ef7d 2022-06-27)
π«π· Crafted in Angers, France.
Crisp |
π You use Constellation and you want to be listed there? Contact me.
- Pluggable authoritative DNS server, comes handy if you need to generate eg. email sub-domains for your users (with DKIM, DMARC and SPF records).
- HTTP REST API to check, read, insert, modify and delete DNS records on the fly.
- Persistence layer in Redis. This means you can run multiple Constellations hitting against the same database on the network. You can even shard Redis if you need fault tolerance on the DNS data store.
- Geo-DNS to serve records on a location basis. For instance, serve the IP to your US server for all North America users, and fallback to Europe for the rest. Based on MaxMind GeoLite2 free database, that is automatically updated when necessary.
Constellation is built in Rust. To install it, either download a version from the Constellation releases page, use cargo install
or pull the source code from master
.
π Each release binary comes with an .asc
signature file, which can be verified using @valeriansaliou GPG public key: πvaleriansaliou.gpg.pub.asc.
Install from source:
If you pulled the source code from Git, you can build it using cargo
:
cargo build --release
You can find the built binaries in the ./target/release
directory.
Install libssl-dev
(ie. OpenSSL headers) before you compile Constellation. SSL dependencies are required for the Geo-DNS database updater and the DNS health check system (HTTPS prober).
Install from Cargo:
You can install Constellation directly with cargo install
:
cargo install constellation-server
Ensure that your $PATH
is properly configured to source the Crates binaries, and then run Constellation using the constellation
command.
Install from Docker Hub:
You might find it convenient to run Constellation via Docker. You can find the pre-built Constellation image on Docker Hub as valeriansaliou/constellation.
Pre-built Docker version may not be the latest version of Constellation available.
First, pull the valeriansaliou/constellation
image:
docker pull valeriansaliou/constellation:v1.14.1
Then, seed it a configuration file and run it (replace /path/to/your/constellation/config.cfg
with the path to your configuration file):
docker run -p 53:53/udp -p 8080:8080 -v /path/to/your/constellation/config.cfg:/etc/constellation.cfg -v /path/to/your/constellation/res/:/var/lib/constellation/ valeriansaliou/constellation:v1.14.1
In the configuration file, ensure that:
dns.inets
is set to[0.0.0.0:53]
(this lets Constellation DNS be reached from outside the container)http.inet
is set to0.0.0.0:8080
(this lets Constellation REST API be reached from outside the container)geo.database_path
is set to/var/lib/constellation/geo/
(this is where the GeoIP database is stored)
Constellation will be reachable by DNS resolvers from udp://localhost:53
; while its internal REST API will be reachable from http://localhost:8080
.
Also, do not forget to initialize the GeoIP database in the ./res/geo/
folder (refer to the part on how to Initialize GeoIP below).
Use the sample config.cfg configuration file and adjust it to your own environment.
Available configuration options are commented below, with allowed values:
[server]
log_level
(type: string, allowed:debug
,info
,warn
,error
, default:error
) β Verbosity of logging, set it toerror
in productionidentifier
(type: string, allowed: text values, default:constellation/0
) β Identifier of this Constellation server in the pool of replicas (used for identification and notification purposes)
[dns]
inets
(type: array[string], allowed: IPs + ports, default:[0.0.0.0:53, [::]:53]
) β Hosts and UDP/TCP ports the DNS server should listen ontcp_timeout
(type: integer, allowed: seconds, default:2
) β Timeout of DNS over TCP connectionsnameservers
(type: array[string], allowed: domain names, default: no default) β Name server domains for all served domainssoa_master
(type: string, allowed: domain names, default: no default) β SOA master domain for all zones served by this name server (name of primary NS server)soa_responsible
(type: string, allowed: email addresses as domain names, default: no default) β SOA responsible email for all zones served by this name serversoa_refresh
(type: integer, allowed: seconds, default:10000
) β SOA record refresh valuesoa_retry
(type: integer, allowed: seconds, default:2400
) β SOA record retry valuesoa_expire
(type: integer, allowed: seconds, default:604800
) β SOA record expire valuesoa_ttl
(type: integer, allowed: seconds, default:3600
) β SOA record TTL valuerecord_ttl
(type: integer, allowed: seconds, default:3600
) β DNS records TTL value
[[dns.zone.'{name}']]
Specify your zone name eg. as:
[[dns.zone.'relay.crisp.chat']]
for zone base:relay.crisp.chat
.
[dns.flatten]
resolvers
(type: array[string], allowed: hostname, IPv4, IPv6, default: no default) β DNS resolvers that should be used when flattening a CNAME record
[dns.health]
check_enable
(type: boolean, allowed:true
,false
, default:false
) β Whether to perform periodic health checks or notcheck_interval
(type: integer, allowed: seconds, default:60
) β Interval for which to perform health checks in seconds (from 1 minute to 5 minutes is recommended)
[dns.health.notify]
slack_hook_url
(type: string, allowed: URL, default: no default) β Slack hook URL for notifications (ie.https://hooks.slack.com/[..]
)
[[dns.health.http]]
zone
(type: string, allowed: any zone root domain, default: no default) β Root domain for zone to be checked (eg.relay.crisp.chat
)name
(type: string, allowed: any subdomain on zone, default: no default) β Subdomain for zone to be checked (eg.client.@
, for expanded domainclient.relay.crisp.chat
)method
(type: string, allowed:HEAD
,GET
, default:GET
) β HTTP method to be used by HTTP health probe to perform the check requesthost
(type: string, allowed: HTTP virtual hosts, default: empty) β HTTP virtual host to be requested upon check (if not set, it is generated fromzone
andname
)path
(type: string, allowed: HTTP paths, default:/
) β HTTP path to be requested upon checkport
(type: integer, allowed: TCP ports, default:443
) β TCP port used for HTTP check (port value will likely be80
if HTTP is used)secure
(type: boolean, allowed:true
,false
, default:true
) β Whether to perform health checks over secure HTTPS or plain HTTPtimeout
(type: integer, allowed: seconds, default:10
) β Timeout of a single HTTP check attempt in secondsmax_attempts
(type: integer, allowed: numbers, default:3
) β Maximum number of times to attempt a given health check in a row, in the event of a failed health check (ie. an health check that neither matches expected status and expected body)expected_status
(type: array[integer], allowed: HTTP status codes, default:200
) β List of HTTP status codes to expectexpected_body
(type: array[string], allowed: text values, default: empty) β List of body contents to expect (sub-string can be contained in response body; only applicable ifmethod
is set toGET
)
[geo]
database_path
(type: string, allowed: folder path, default:./res/geo/
) β Path to the folder containing the GeoIP databasedatabase_file
(type: string, allowed: file name, default:GeoLite2-Country.mmdb
) β File name for the GeoIP2 MMDB database in the database folder (either free GeoLite2 or paid GeoIP2; enablegeo.update_enable
if you want to automatically update this file from a remote download server)update_enable
(type: boolean, allowed:true
,false
, default:false
) β Whether to enable GeoIP database updater or notupdate_interval
(type: integer, allowed: seconds, default:864000
) β Interval for which to refresh GeoIP database in seconds (1 week or more is recommended)update_url
(type: string, allowed: HTTP URL, default: empty) β URL to the compressed GeoIP MMDB file (supported:tar.gz
), that is downloaded on refresh (a value is required ifgeo.update_enable
is enabled)
[http]
inet
(type: string, allowed: IPv4 / IPv6 + port, default:[::1]:8080
) β Host and TCP port the HTTP API server should listen onworkers
(type: integer, allowed: any number, default:2
) β Number of workers for the HTTP API server to run onrecord_token
(type: string, allowed: secret token, default: no default) β Record secret token for management API access (ie. secret password)
[redis]
database
(type: integer, allowed:0
to255
, default:0
) β Target Redis databasepool_size
(type: integer, allowed:0
to(2^32)-1
, default:8
) β Redis connection pool sizemax_lifetime_seconds
(type: integer, allowed: seconds, default:20
) β Maximum lifetime of a connection to Redis (you want it below 5 minutes, as this affects the reconnect delay to Redis if a connection breaks)idle_timeout_seconds
(type: integer, allowed: seconds, default:600
) β Timeout of idle/dead pool connections to Redisconnection_timeout_seconds
(type: integer, allowed: seconds, default:5
) β Timeout in seconds to consider Redis dead and reject DNS and HTTP API queriescache_refresh_seconds
(type: integer, allowed: seconds, default:60
) β Time in seconds after which a locally-cached record is refreshed from Redis (this should be kept low)cache_expire_seconds
(type: integer, allowed: seconds, default:600
) β Time in seconds after which a locally-cached record expires and should be refreshed from Redis (this should be kept low)
[redis.master]
host
(type: string, allowed: hostname, IPv4, IPv6, default:localhost
) β Target master Redis hostport
(type: integer, allowed: TCP port, default:6379
) β Target master Redis TCP portpassword
(type: string, allowed: password values, default: none) β Master Redis password (if no password, do not set this key)
[[redis.rescue]]
host
(type: string, allowed: hostname, IPv4, IPv6, default:localhost
) β Read-only rescue Redis hostport
(type: integer, allowed: TCP port, default:6379
) β Read-only rescue Redis TCP portpassword
(type: string, allowed: password values, default: none) β Read-only rescue Redis password (if no password, do not set this key)
As Constellation does not distribute a GeoIP database in its repository, you will need to fetch it from MaxMind before you run Constellation for the first time (Constellation will refuse to start otherwise).
Execute the provided script:
./scripts/init_geoip.sh --license_key=YOUR_GEOLITE2_LICENSE_KEY
YOUR_GEOLITE2_LICENSE_KEY should be replaced by a valid GeoLite2 license key. Please follow instructions provided by MaxMind to obtain a license key.
Note that once Constellation started from the GeoIP database you have manually initialized, it will keep the database up-to-date by checking and applying updates automatically in the background. The database initialization is a one-time operation. Make sure your license key is also set in the GeoIP update URL in the configuration.
Constellation can be run as such:
./constellation -c /path/to/config.cfg
Once running, DNS queries can be made against Constellation over the local network (using the default configuration):
dig subdomain.relay.crisp.chat @::1
Note that the dig
utility can be pointed to a specific server with the @
modifier, here with IPv6 localhost: ::1
.
The Constellation HTTP REST API listens on the configured http.inet
interface from your config.cfg
file. You can use it for your management and monitoring needs.
If you want to play with the API the easy way, an up-to-date Paw file is available with all API routes and example requests. Download the Paw app for your Mac there (Paw a tool developers use to test their APIs).
To check, read, insert, modify and delete DNS records, you can use the zone
API resource.
Endpoint URL:
HTTP http://constellation.local:8080/zone/<zone_name>/record/<record_name>/<record_type>/
Where:
zone_name
: The zone name (ie. base domain), eg.relay.crisp.chat
record_name
: The record name to read or alter (ie. sub-domain or base domain), eg.client.@
for theclient.relay.crisp.chat
FQDN, or@
for therelay.crisp.chat
FQDNrecord_type
: The DNS record type to read or alter for therecord_name
; either:a
,aaaa
,cname
,mx
,txt
orptr
(open an issue if you need support for another record type)
Request headers:
- Add an
Authorization
header with aBasic
authentication where the password is your configuredhttp.record_token
.
Geo-DNS regions:
If you want to serve records to the nearest server using the Geo-DNS feature, you will need to set regions
via the API, where:
-
Americas
nnam
: Northern North Americasnam
: Southern North Americansam
: Northern South Americassam
: Southern South America
-
Europe
weu
: Western Europeceu
: Central Europeeeu
: Eastern Europeru
: Russia
-
Middle East
me
: Middle East
-
Africa
naf
: Northern Africamaf
: Middle Africasaf
: Southern Africa
-
Asia
in
: Indiaseas
: Southeast Asianeas
: Northeast Asia
-
Oceania
oc
: Oceania
Geo-DNS blackhole:
If you want to return an empty DNS response for blocked countries using the Geo-DNS feature, you will need to set blackhole
via the API, to a list of blackholed ISO-3166 Alpha-2 country codes (eg. FR
for France).
Rescue records for health-check:
In case you are using health-check on the domain for zone, you may want to specify rescue records, that are served to DNS clients in the event all regular records (standard and Geo-DNS) are seen as dead. You can set the rescue
property in the API to ensure failover servers are served, and connected to only in the event of a failure of default servers.
If you do not set any rescue
records; in the event all regular records get reported as dead, DNS clients will be served an empty response. Thus, it is judicious that you still serve fallback records.
CNAME flattening:
CNAMEs are handy to centralize record values in a single DNS entry, and re-use it across multiple DNS CNAME entries. It has its caveats, as for instance, it is illegal as per the DNS RFC to share it with other records on the same sub-domain. It is also illegal to setup a CNAME at the root of a domain. Furthermore, CNAMEs require DNS resolvers to perform a second resolving step as to resolve the flat value (eg. A
, AAAA
, TXT
, etc. records), which is not super efficient as it adds extraneous latency when users resolve a domain using a CNAME.
CNAME flattening can help if you encounter an edge case of the DNS RFC with a CNAME record type. It lets Constellation resolve the actual flat value, and serve it right away, instead of returning the actual CNAME. CNAME flattening can be enabled for a record by setting the flatten
property in the API to true
. By default, no CNAME flattening is performed.
A dedicated Constellation thread manages previously-flattened CNAME values, and updates them as they change on their remote DNS server. As well, if a cached flattened CNAME has not been used for a long time, it is expunged from cache. Note that, due to the fact that Constellation is mono-threaded, if a CNAME value with flattening enabled is not yet in cache, then Constellation will answer with the CNAME back, and delegate a deferred flatten order to the flattening manager thread, in order to avoid blocking the main DNS server thread. Once the flattening manager thread has done its work, further DNS queries to the CNAME will then be answered with the flattened value (eg. it will return flat A
record values, instead of the CNAME
value).
Note that the flatten
option is only applicable to records with CNAME values. If flattening is enabled on eg. a A
record type, the flatten
property will have no effect.
HTTP HEAD http://constellation.local:8080/zone/<zone_name>/record/<record_name>/<record_type>/
Example request:
HEAD /zone/relay.crisp.chat/record/@/a HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Example response:
HTTP/1.1 200 OK
HTTP GET http://constellation.local:8080/zone/<zone_name>/record/<record_name>/<record_type>/
Example request:
GET /zone/relay.crisp.chat/record/@/a HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Example response:
HTTP/1.1 200 OK
Content-Type: application/json
{"type":"a","name":"@","ttl":600,"blackhole": null,"regions": null,"values":["159.89.97.13","46.101.18.133"]}
HTTP PUT http://constellation.local:8080/zone/<zone_name>/record/<record_name>/<record_type>/
Example request (standard):
PUT /zone/relay.crisp.chat/record/@/a HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Content-Type: application/json; charset=utf-8
{"values":["159.89.97.13","46.101.18.133"],"ttl":600}
Example request (Geo-DNS):
PUT /zone/relay.crisp.chat/record/@/cname HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Content-Type: application/json; charset=utf-8
{"regions":{"nnam":["client.nnam.geo.relay.crisp.net"],"snam":["client.snam.geo.relay.crisp.net"],"nsam":["client.nsam.geo.relay.crisp.net"],"ssam":["client.ssam.geo.relay.crisp.net"],"weu":["client.weu.geo.relay.crisp.net"],"ceu":["client.ceu.geo.relay.crisp.net"],"eeu":["client.eeu.geo.relay.crisp.net"],"ru":["client.ru.geo.relay.crisp.net"],"me":["client.me.geo.relay.crisp.net"],"naf":["client.naf.geo.relay.crisp.net"],"maf":["client.maf.geo.relay.crisp.net"],"saf":["client.saf.geo.relay.crisp.net"],"in":["client.in.geo.relay.crisp.net"],"seas":["client.seas.geo.relay.crisp.net"],"neas":["client.neas.geo.relay.crisp.net"],"oc":["client.oc.geo.relay.crisp.net"]},"values":["client.default.geo.relay.crisp.net"],"ttl":600}
Example request (health-checked):
PUT /zone/relay.crisp.chat/record/@/a HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Content-Type: application/json; charset=utf-8
{"values":["159.89.97.13","46.101.18.133"],"rescue":["139.59.174.13"],"ttl":60}
Example response:
HTTP/1.1 200 OK
Example request (CNAME-flattened):
PUT /zone/relay.crisp.chat/record/@/cname HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Content-Type: application/json; charset=utf-8
{"values":["alias.crisp.net"],"flatten":true,"ttl":60}
Example response:
HTTP/1.1 200 OK
HTTP DELETE http://constellation.local:8080/zone/<zone_name>/record/<record_name>/<record_type>/
Example request:
DELETE /zone/relay.crisp.chat/record/@/a HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Example response:
HTTP/1.1 200 OK
To obtain server usage metrics (eg. which countries DNS requests come), you can use the metrics
API resource.
Endpoint URL:
HTTP http://constellation.local:8080/zone/<zone_name>/metrics/<metrics_timespan>/<metrics_category>/<metrics_type>
Where:
zone_name
: The zone name (ie. base domain), eg.relay.crisp.chat
metrics_timespan
: The timespan over which metrics should be returned (either:1m
,5m
or15m
), which stands for: metrics for the last 'n-th' minutesmetrics_category
: The metrics category (either:query
oranswer
)metrics_type
: The metrics type in category (either:types
ororigins
if category isquery
, orcodes
if category isanswer
)
Request headers:
- Add an
Authorization
header with aBasic
authentication where the password is your configuredhttp.record_token
.
HTTP GET http://constellation.local:8080/zone/<zone_name>/metrics/<metrics_timespan>/<metrics_category>/<metrics_type>/
Example request:
GET /zone/relay.crisp.chat/metrics/5m/query/origins HTTP/1.1
Authorization: Basic OlJFUExBQ0VfVEhJU19XSVRIX0FfU0VDUkVUX0tFWQ==
Example response:
HTTP/1.1 200 OK
Content-Type: application/json
{"fr":1203,"us":899,"lv":23,"gb":10,"other":2}
If you find a vulnerability in Constellation, you are more than welcome to report it directly to @valeriansaliou by sending an encrypted email to valerian@valeriansaliou.name. Do not report vulnerabilities in public GitHub issues, as they may be exploited by malicious people to target production servers running an unpatched Constellation instance.