No description
  • Rust 89.2%
  • Nix 10.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-19 21:52:04 +02:00
crates scope: do not use *. as wildcard 2026-10-19 15:04:36 +02:00
examples scope: do not use *. as wildcard 2026-10-19 15:04:36 +02:00
nix rust: add and use rust-toolchain.toml 2026-10-19 21:52:04 +02:00
.gitignore feat(hostingde): async client for the hosting.de JSON API 2026-10-17 23:26:52 +02:00
Cargo.lock feat(dnsd): RFC 2136 backend with TSIG 2026-10-17 23:32:23 +02:00
Cargo.toml feat(dnsd): provider abstraction with the hosting.de backend 2026-10-17 23:30:38 +02:00
flake.lock feat(nix): package both crates behind a flake 2026-10-17 23:33:18 +02:00
flake.nix rust: add and use rust-toolchain.toml 2026-10-19 21:52:04 +02:00
README.md scope: do not use *. as wildcard 2026-10-19 15:04:36 +02:00
rust-toolchain.toml rust: add and use rust-toolchain.toml 2026-10-19 21:52:04 +02:00

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, authToken in the body.
  • Updates go through recordsUpdate with recordsToAdd, recordsToModify and recordsToDelete. 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 stays blocked until the name servers have the change. wait_until_active polls until it is active again.
  • Errors come back with HTTP 200 and a list of error objects; they turn into Error::Api with 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