- Rust 89.2%
- Nix 10.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| crates | ||
| examples | ||
| nix | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| rust-toolchain.toml | ||
dnsd & hostingde
A scoped DNS update daemon, and the hosting.de client it grew out of.
| Crate | What it is |
|---|---|
crates/dnsd |
dnsd — holds the provider credentials and hands out narrow keys: "may set A/AAAA under .fbs42.de, nothing else". Backends: hosting.de and RFC 2136. |
crates/hostingde |
the hosting.de API client plus hostingde-dns, a CLI for listing zones and setting, adding and deleting records. |
nix build .#dnsd # ./result/bin/dnsd
nix build .#hostingde-dns # ./result/bin/hostingde-dns
cargo build --release # both, in target/release
dnsd
A provider's credentials are usually all-or-nothing: the hosting.de key that may
publish home.fbs42.de may just as well rewrite every record of the account.
dnsd keeps that credential to itself and accepts its own keys, each bound to a
backend, a zone, a set of name patterns and a set of record types.
location /upd/ {
access_log off; # keys travel in the query string
proxy_pass http://127.0.0.1:8431;
proxy_set_header X-Forwarded-For $remote_addr;
}
curl 'https://example.net/upd/home.fbs42.de?apikey=K&ip=192.0.2.5&ip6=2001:db8::1'
good 192.0.2.5 2001:db8::1
curl 'https://example.net/upd/home.fbs42.de?apikey=K&ip=192.0.2.5&ip6=2001:db8::1'
nochg 192.0.2.5 2001:db8::1
curl 'https://example.net/upd/home.fbs42.de?apikey=K' # address of the caller
good 198.51.100.7
| Situation | Reply |
|---|---|
| record set written | 200 good <addresses> |
| already correct, nothing sent to the backend | 200 nochg <addresses> |
| no key, or an unknown one | 401 badauth |
| name or record type outside the key's scope | 403 !yours |
not a host name / unusable ip |
400 notfqdn, 400 badparam |
| key is over its rate limit | 429 abuse |
| the backend refused or was unreachable | 502 dnserr |
The reply is text/plain; add ?format=json or Accept: application/json for
JSON. The key may also be sent as Authorization: Bearer <token>, which keeps it
out of proxy logs. ip/ip6 win over the address of the caller, which is taken
from X-Real-IP/X-Forwarded-For only when the peer is a trusted proxy.
GET /healthz answers ok without a key.
dnsd --config /etc/dnsd.toml # serve
dnsd --config /etc/dnsd.toml check # print backends and scopes
dnsd --config /etc/dnsd.toml check --probe # …and talk to each backend
dnsd gen-token # a fresh 32 byte token
Backends
type |
Writes through | Needs |
|---|---|---|
hostingde |
the hosting.de JSON API (recordsUpdate) |
an API key with DNS_ZONES_LIST + DNS_ZONES_EDIT |
rfc2136 |
DNS UPDATE messages, optionally TSIG signed | a server that accepts updates (BIND, Knot, PowerDNS, …) |
Both are driven through the same small trait (dnsd::provider::Provider): read
the record sets a request touches, write the ones that really differ. Everything
else — the cache, one write at a time per zone, the diff, the scope checks — is
shared, so another backend is just those few methods.
Configuration
# /etc/dnsd.toml — policy only, no secrets
listen = "127.0.0.1:8431"
trusted_proxies = ["127.0.0.1", "::1"]
# Files and directories mixed as you like: a file is a TOML map of
# key name → token, a directory holds one file per key.
tokens = ["/run/secrets/dnsd-tokens", "/run/secrets/dnsd.d"]
[backends.fbs42]
type = "hostingde"
cache_seconds = 60 # 0 disables the cache
api_key_file = "/run/secrets/hostingde-api-key"
settle_timeout_seconds = 30 # hosting.de blocks a zone while it applies
[backends.lab]
type = "rfc2136"
cache_seconds = 30
server = "10.0.0.53:53"
protocol = "udp" # udp | tcp
tsig_name = "dnsd." # omit for a server that authorizes by address
tsig_algorithm = "hmac-sha256"
tsig_secret_file = "/run/secrets/lab-tsig" # base64, as tsig-keygen prints it
[keys.nginx]
backend = "fbs42" # optional with a single backend
zone = "fbs42.de"
names = [".fbs42.de"] # `.` matches any depth, but not the zone itself
types = ["A", "AAAA"]
ttl = 60
rate_limit = { burst = 10, per_minute = 6 }
[keys.lab-host]
backend = "lab"
zone = "lab.internal"
names = ["host.lab.internal"]
token_file = "/run/secrets/lab-host-key" # a file holding just this token
A key can also carry its token inline (token = "…"). Under systemd the
credentials in $CREDENTIALS_DIRECTORY are picked up automatically:
backend-<name>-api-key and backend-<name>-tsig for backend secrets, and
tokens, tokens-<n>, tokens-<n>_<key>, token-<key> for caller tokens —
which is why a directory passed to LoadCredential just works.
Every key must end up with exactly one token, and every key must name a backend
that exists; a key without a token, two sources for one key, a token for a key
that does not exist, a TTL below what the backend accepts, or a pattern outside
its zone all stop the daemon at startup. SIGHUP (systemctl reload) re-reads
configuration, backends and tokens without dropping the socket.
Updates are idempotent and cheap: record sets are cached per backend, A and AAAA travel in a single backend call, writes to one zone are serialized, and a caller re-announcing the address it already has costs nothing.
Nix
nix run .#dnsd -- --config /etc/dnsd.toml check
nix develop # cargo, rustc, clippy, rust-analyzer
nix flake check # builds both crates and runs their tests
Outputs: packages.<system>.{dnsd,hostingde-dns,default}, apps,
overlays.default (adds pkgs.dnsd and pkgs.hostingde-dns),
nixosModules.{dnsd,hostingde-dns,default}, devShells, checks, formatter.
The daemon as a module
{
imports = [ dnsd.nixosModules.default ];
services.dnsd = {
enable = true;
listen = "127.0.0.1:8431";
tokens = [
config.age.secrets.dnsd-tokens.path # a file of name = "token"
"/run/secrets/dnsd.d" # or a directory, one file per key
];
backends.fbs42 = {
type = "hostingde";
hostingde.apiKeyFile = config.age.secrets.hostingde-api-key.path;
};
backends.lab = {
type = "rfc2136";
cacheSeconds = 30;
rfc2136 = {
server = "10.0.0.53";
protocol = "udp";
tsig = {
name = "dnsd.";
algorithm = "hmac-sha256";
secretFile = config.age.secrets.lab-tsig.path;
};
};
};
keys.nginx = {
backend = "fbs42";
zone = "fbs42.de";
names = [ ".fbs42.de" ];
types = [ "A" "AAAA" ];
ttl = 60;
};
keys.lab-host = {
backend = "lab";
zone = "lab.internal";
names = [ "host.lab.internal" ];
tokenFile = config.age.secrets.lab-host-key.path;
};
};
}
Each backend type has its own typed option block; filling in the wrong one, a key pointing at a missing backend, a TTL hosting.de would reject and a pattern outside its zone are all build time errors.
Every secret is passed in as a systemd credential, so the unit runs as a
DynamicUser: systemd reads the files as root and gives the service a private,
read-only copy — the ownership and mode of your /run/secrets files do not
matter, and no token or secret path ends up in the Nix store. (One systemd
detail: inside a directory source symlinks are skipped, so such a directory
must hold real files.) The generated configuration is policy only, so
systemctl reload dnsd is enough after a change.
hostingde-dns
The command line tool for the hosting.de API: everything dnsd cannot do,
because it is not a dyndns update.
export HOSTINGDE_API_KEY=...
hostingde-dns zones # list zones
hostingde-dns records example.com --type A # list records
# Replace the complete record set of a name and type:
hostingde-dns set example.com www A 192.0.2.1 --ttl 300
hostingde-dns set example.com www A 192.0.2.1 192.0.2.2 --ttl 300 # two A records
hostingde-dns set example.com @ AAAA 2001:db8::1 --wait # wait until live
hostingde-dns set example.com @ MX "10 mail.example.com" "20 mx2.example.com"
hostingde-dns set example.com @ TXT "v=spf1 include:example.net -all"
hostingde-dns set example.com secure DS "12345 8 2 49FD46E6..." # DNSSEC records
# Add or remove individual records, leaving their siblings alone:
hostingde-dns add example.com www A 192.0.2.3
hostingde-dns delete example.com www A 192.0.2.3
hostingde-dns delete example.com www A # the whole record set
hostingde-dns wait example.com --timeout 300
| Variable | Meaning |
|---|---|
HOSTINGDE_API_KEY |
API key, sent as authToken (required) |
HOSTINGDE_OWNER_ACCOUNT_ID |
run every request in the name of this subaccount |
HOSTINGDE_API_URL |
alternative entry point, default https://secure.hosting.de/api |
set is idempotent: the zone is read first, records that already match are
left alone, and if nothing has to change no changing request is sent at all
(unchanged: www.example.com A). For MX and SRV the priority may be written
inline ("10 mail.example.com"); --priority sets it for all values. Record
names are relative to the zone: www becomes www.example.com, @ is the apex,
and an absolute name is kept as it is.
--json prints the affected records as JSON, -v/-vv logs the requests, and
every failure exits with status 1 and the full error chain on stderr.
As a module
services.hostingde-dns keeps record sets up to date with a systemd timer per
record — the declarative counterpart to the daemon's push model.
services.hostingde-dns = {
enable = true;
apiKeyFile = config.age.secrets.hostingde-api-key.path;
extraPackages = [ pkgs.curl ];
records.home = {
zone = "example.com";
type = "A";
valuesCommand = "curl -fsS https://ifconfig.me";
ttl = 60;
interval = "*:0/5";
};
records.mail = {
zone = "example.com";
name = "@";
type = "MX";
values = [ "10 mail.example.com" "20 mx2.example.com" ];
ttl = 3600;
interval = "daily";
};
};
A record needs either values or a valuesCommand, and a valuesCommand that
fails or prints nothing aborts the run — neither a typo nor a hiccup in your IP
lookup can empty a record set.
As a library
[dependencies]
hostingde = { version = "0.1", default-features = false, features = ["rustls-tls"] }
use std::net::Ipv4Addr;
use std::time::Duration;
#[tokio::main]
async fn main() -> hostingde::Result<()> {
let client = hostingde::Client::from_env()?; // HOSTINGDE_API_KEY
let dns = client.dns();
let outcome = dns
.set_a("example.com", "www", [Ipv4Addr::new(192, 0, 2, 1)], Some(300))
.await?;
if outcome.changed {
dns.wait_until_active("example.com", Duration::from_secs(120)).await?;
}
Ok(())
}
Beyond set_a there are set_aaaa, set_cname, set_txt, set_mx and the
generic set_rrset / apply_rrset / delete_rrset / add_record /
delete_record; every record type the API knows is available through
RecordType, including the DNSSEC ones. The raw methods zones_find,
records_find and records_update take and return the API's own structures, and
Client::call(service, method, params) reaches any other method of the API.
How it maps onto the API
- Endpoint
POST https://secure.hosting.de/api/dns/v1/json/<method>, body JSON,authTokenin the body. - Updates go through
recordsUpdatewithrecordsToAdd,recordsToModifyandrecordsToDelete. The diff keeps matching records, modifies records whose TTL or priority changed, and adds or deletes the rest — in a single call. - Changing calls answer with
status: "pending"; the zone staysblockeduntil the name servers have the change.wait_until_activepolls until it isactiveagain. - Errors come back with HTTP 200 and a list of error objects; they turn into
Error::Apiwith the API's code, text and JSON pointer preserved.
Not covered: creating or deleting zones, templates, nameserver sets, switching a zone's DNSSEC signing mode, and the non-DNS services.
Tests
cargo test --workspace # unit tests, a mocked hosting.de API and a fake name
# server — no network needed