constellation-server

Pluggable authoritative DNS server. Entries can be added & removed from an HTTP REST API.


Keywords
dns, api, authoritative, infrastructure, redis, rust, server
License
MPL-2.0

Documentation

Constellation

Test and Build Build and Release dependency status Buy Me A Coffee

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.

Constellation

Who uses it?

Crisp

πŸ‘‹ You use Constellation and you want to be listed there? Contact me.

Features

  • 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.

How to use it?

Installation

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 packages:

Constellation provides pre-built packages for Debian-based systems (Debian, Ubuntu, etc.).

Important: Constellation only provides 64 bits packages targeting Debian 10, 11 & 12 for now (codenames: buster, bullseye & bookworm). You will still be able to use them on other Debian versions, as well as Ubuntu.

First, add the Constellation APT repository (eg. for Debian bookworm):

echo "deb https://packagecloud.io/valeriansaliou/constellation/debian/ bookworm main" > /etc/apt/sources.list.d/valeriansaliou_constellation.list
curl --silent -L https://packagecloud.io/valeriansaliou/constellation/gpgkey | apt-key add -
apt-get update

Then, install the Constellation package:

apt-get install constellation

Then, edit the pre-filled Constellation configuration file:

nano /etc/constellation.cfg

Finally, restart Constellation:

service constellation restart

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.3

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.3

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 to 0.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).

Configuration

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 to error in production
  • identifier (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 on
  • tcp_timeout (type: integer, allowed: seconds, default: 2) β€” Timeout of DNS over TCP connections
  • nameservers (type: array[string], allowed: domain names, default: no default) β€” Name server domains for all served domains
  • soa_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 server
  • soa_refresh (type: integer, allowed: seconds, default: 10000) β€” SOA record refresh value
  • soa_retry (type: integer, allowed: seconds, default: 2400) β€” SOA record retry value
  • soa_expire (type: integer, allowed: seconds, default: 604800) β€” SOA record expire value
  • soa_ttl (type: integer, allowed: seconds, default: 3600) β€” SOA record TTL value
  • record_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 not
  • check_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 domain client.relay.crisp.chat)
  • method (type: string, allowed: HEAD, GET, default: GET) β€” HTTP method to be used by HTTP health probe to perform the check request
  • host (type: string, allowed: HTTP virtual hosts, default: empty) β€” HTTP virtual host to be requested upon check (if not set, it is generated from zone and name)
  • path (type: string, allowed: HTTP paths, default: /) β€” HTTP path to be requested upon check
  • port (type: integer, allowed: TCP ports, default: 443) β€” TCP port used for HTTP check (port value will likely be 80 if HTTP is used)
  • secure (type: boolean, allowed: true, false, default: true) β€” Whether to perform health checks over secure HTTPS or plain HTTP
  • timeout (type: integer, allowed: seconds, default: 10) β€” Timeout of a single HTTP check attempt in seconds
  • max_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 expect
  • expected_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 if method is set to GET)

[geo]

  • database_path (type: string, allowed: folder path, default: ./res/geo/) β€” Path to the folder containing the GeoIP database
  • database_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; enable geo.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 not
  • update_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 if geo.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 on
  • workers (type: integer, allowed: any number, default: 2) β€” Number of workers for the HTTP API server to run on
  • record_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 to 255, default: 0) β€” Target Redis database
  • pool_size (type: integer, allowed: 0 to (2^32)-1, default: 8) β€” Redis connection pool size
  • max_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 Redis
  • connection_timeout_seconds (type: integer, allowed: seconds, default: 5) β€” Timeout in seconds to consider Redis dead and reject DNS and HTTP API queries
  • cache_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 host
  • port (type: integer, allowed: TCP port, default: 6379) β€” Target master Redis TCP port
  • password (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 host
  • port (type: integer, allowed: TCP port, default: 6379) β€” Read-only rescue Redis TCP port
  • password (type: string, allowed: password values, default: none) β€” Read-only rescue Redis password (if no password, do not set this key)

Initialize GeoIP

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.

Run Constellation

Constellation can be run as such:

./constellation -c /path/to/config.cfg

Test Constellation

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.

πŸ›° HTTP REST API

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).

1. DNS records management

To check, read, insert, modify and delete DNS records, you can use the zone API resource.

API overview

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 the client.relay.crisp.chat FQDN, or @ for the relay.crisp.chat FQDN
  • record_type: The DNS record type to read or alter for the record_name; either: a, aaaa, cname, mx, txt or ptr (open an issue if you need support for another record type)

Request headers:

  • Add an Authorization header with a Basic authentication where the password is your configured http.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 America
    • snam: Southern North America
    • nsam: Northern South America
    • ssam: Southern South America
  • Europe

    • weu: Western Europe
    • ceu: Central Europe
    • eeu: Eastern Europe
    • ru: Russia
  • Middle East

    • me: Middle East
  • Africa

    • naf: Northern Africa
    • maf: Middle Africa
    • saf: Southern Africa
  • Asia

    • in: India
    • seas: Southeast Asia
    • neas: 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.

API routes

Check if a DNS record exists

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
Get a DNS record

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"]}
Write a DNS record (or overwrite existing)

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
Delete a DNS record

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

2. Server usage metrics retrieval

To obtain server usage metrics (eg. which countries DNS requests come), you can use the metrics API resource.

API overview

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 or 15m), which stands for: metrics for the last 'n-th' minutes
  • metrics_category: The metrics category (either: query or answer)
  • metrics_type: The metrics type in category (either: types or origins if category is query, or codes if category is answer)

Request headers:

  • Add an Authorization header with a Basic authentication where the password is your configured http.record_token.

API routes

Get metrics

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}

πŸ”₯ Report A Vulnerability

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.

⚠️ You must encrypt your email using @valeriansaliou GPG public key: πŸ”‘valeriansaliou.gpg.pub.asc.