nix: a services.nxdns module that runs the packaged unit in file mode against a configuration the pinned binary accepted

This commit is contained in:
2026-10-20 13:06:43 +02:00
parent aa8c9e28af
commit f35146918b
9 changed files with 303 additions and 12 deletions
+10
View File
@@ -49,6 +49,16 @@ That function returns `null` for every input. `fetchAdd` is the only trigger: a
`Owner.published` in `src/upstream/owner.zig` is a plain `u64` under the owner's mutex for this reason. Do not "modernize" it to `std.atomic.Value(u64)`.
## Testing the NixOS module
`nix/module.nix` (`services.nxdns`) has two acceptance checks in `nix/tests.nix`, and no CI job runs them: the VM test needs KVM, and both download a nixpkgs closure that the shared Gitea runner must not fetch on every push. The cut's `nix flake check --no-build` evaluates them, which catches a module that no longer evaluates, but builds nothing. After any change to `nix/module.nix`, `nix/tests.nix`, the package in `flake.nix` or `deploy/systemd/nxdns.service`, build both on a host with KVM before the commit:
```
nix build --no-link -L .#checks.x86_64-linux.nixos-module .#checks.x86_64-linux.nixos-module-rejects-config
```
The checks run the package the flake pins, which is the last release, so a change to `deploy/systemd/nxdns.service` reaches the VM test only after the release that ships it.
## Regenerating the contract samples
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
+1
View File
@@ -14,6 +14,7 @@ Sections are written by hand. Nothing here is generated from commit messages: th
### Added
- **A NixOS module.** The flake exports `nixosModules.default`, which adds `services.nxdns` with four options: `enable`, `package`, `configFile` and `openFirewall`. It runs the release's own `nxdns.service` with a drop-in for file mode, checks `configFile` with the pinned binary's `nxdns import` at build time so a configuration that does not parse, validate or reconcile fails the build, and restarts the service when only the configuration changes. The flake's package now also installs `lib/systemd/system/nxdns.service`. See "Install nxdns with Nix".
- **`nxdns_upstream_local_resource_total`** counts exchanges that ended on this process's own resources (memory, file descriptors, an unnamed system error) before any upstream answered. Until now, such a SERVFAIL left no record of its cause anywhere.
### Changed
+1 -1
View File
@@ -51,7 +51,7 @@ The compose file names the release it belongs to, so a checkout of a release tag
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes that file the configuration: every start reconciles the database onto it, and the UI refuses configuration edits. Edit the file and restart to change anything. Drop the `command:` line to run bare `nxdns run` instead, where the database is the configuration and changes go through the UI, the API, or `nxdns export` / `nxdns import` — the packaged systemd unit does that. Which mode is live is printed at every start (`authority: database` / `authority: file (<path>)`); see [docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md), [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) and, for NixOS, [docs/how-to/install-with-nix.md](docs/how-to/install-with-nix.md).
## Building from source
+1 -1
View File
@@ -22,7 +22,7 @@ Steps for a goal you already have. They assume you know what nxdns is.
- [how-to/verify-a-release.md](how-to/verify-a-release.md) — check the signature and the checksums before you run anything, and what they prove.
- [how-to/install-with-systemd.md](how-to/install-with-systemd.md) — a real install as a system service, including the Raspberry Pi 5 aarch64 binary.
- [how-to/install-with-docker.md](how-to/install-with-docker.md) — the published container image and the compose file.
- [how-to/install-with-nix.md](how-to/install-with-nix.md) — the flake input pinned to a release tag, and Renovate for tag bumps.
- [how-to/install-with-nix.md](how-to/install-with-nix.md) — the flake input pinned to a release tag, the `services.nxdns` NixOS module, and Renovate for tag bumps.
- [how-to/upgrade.md](how-to/upgrade.md) — move to a new release without losing state.
- [how-to/troubleshoot.md](how-to/troubleshoot.md) — what to do when it does not answer, does not block, or will not start.
- [how-to/enable-doh-and-dot.md](how-to/enable-doh-and-dot.md) — serve encrypted DNS with certificates.
+47 -9
View File
@@ -1,12 +1,12 @@
# Install nxdns with Nix
Adds nxdns to a NixOS machine as a flake input pinned to a release tag. At the end `pkgs`-style references to `inputs.nxdns.packages.${system}.default` resolve to the published release binary, and a Renovate custom manager opens a pull request when a new tag appears.
Adds nxdns to a NixOS machine as a flake input pinned to a release tag and runs it with the flake's `services.nxdns` module. At the end the machine serves DNS on port 53 from a configuration file that the pinned binary checked at build time, and a Renovate custom manager opens a pull request when a new tag appears.
nxdns publishes its own `flake.nix`. Its packages do not build nxdns from source: each one fetches the release tarball for the target and pins its SHA-256 hash, so a changed byte fails the build. The two supported systems are `aarch64-linux` and `x86_64-linux`, both static musl builds that need nothing on the host.
For the signature and checksum checks a human does once, see [verify a release](verify-a-release.md). For what each configuration field means, see [the configuration reference](../reference/configuration.md).
> Verification: the commands in step 1 and step 2 were run on the machine that wrote this page, against the flake at nxdns 0.0.16. `nix flake check --no-build` passed and `nix build` produced a binary that printed `nxdns 0.0.16`. The consumer snippets in step 3 and step 4 are copied from the shape `rpi.mial.net` already uses for another flake input of the same author; they were not evaluated from this checkout, which is not a NixOS configuration.
> Verification: the commands in step 1 and step 2 were run on the machine that wrote this page, against the flake at nxdns 0.0.16. `nix flake check --no-build` passed and `nix build` produced a binary that printed `nxdns 0.0.16`. Step 3 was verified on 2026-10-20 by the flake's own checks, built with Nix 2.35.3 on an x86_64 host with KVM and the module running the 0.0.22 release: `checks.x86_64-linux.nixos-module` boots a NixOS VM with the module and `openFirewall`, gets `nas.lan` from `dig @127.0.0.1`, finds the packaged unit with its drop-in in `systemctl cat`, finds port 53 open over UDP and TCP, and switches to a configuration-only change that restarts the service and changes the answer; `checks.x86_64-linux.nixos-module-rejects-config` fails a system build on a bad regex with `FAIL rules[0].pattern: '(' is not a valid regex pattern`. The Renovate snippet in step 4 is copied from the shape `rpi.mial.net` already uses for another flake input of the same author.
## 1. Add the input, pinned to a tag
@@ -42,18 +42,56 @@ nix eval .#packages.x86_64-linux.default.outPath
Evaluation does not fetch the tarball. The download happens at build time, and the hash in nxdns's flake is what the build asserts the bytes against.
## 3. Use the package in a NixOS configuration
## 3. Run the service
Pass the flake inputs to the module system, then reference the package:
`nixosModules.default` exists from the first release after `v0.0.22`; pin that tag or a later one. A tag up to `v0.0.22` has the package only.
```nix
{ inputs, pkgs, ... }:
{
environment.systemPackages = [ inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default ];
Write the configuration next to the NixOS configuration that uses it. The smallest file that starts is one group named `default` and one enabled upstream:
```zon
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
```
The derivation installs `bin/nxdns`, plus `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary. Run it as a service with the unit from [install with systemd](install-with-systemd.md), or write your own module around it.
Pass the flake inputs to the module system, import the module, and enable it:
```nix
{ inputs, ... }:
{
imports = [ inputs.nxdns.nixosModules.default ];
services.nxdns = {
enable = true;
configFile = ./nxdns-config.zon;
openFirewall = true;
};
}
```
Then `nixos-rebuild switch`. The module has four options:
| Option | Default | What it does |
| --- | --- | --- |
| `enable` | `false` | Runs nxdns as `nxdns.service`, as the `nxdns` system user. |
| `package` | the release of the pinned tag | The nxdns release to run. |
| `configFile` | none, required | The configuration, in ZON. It is installed at `/etc/nxdns/config.zon`. |
| `openFirewall` | `false` | Opens port 53 over UDP and TCP. The web interface on port 8080 and the DoH and DoT ports stay closed; open them yourself if you want them reachable from the network. |
`configFile` is copied into the Nix store, which every local user can read. Do not put a plaintext `web.password` in it: put the `web.password_hash` from an export there instead, as [set up admin authentication](set-up-admin-authentication.md) describes.
What the module does with them:
- **The build checks the configuration.** `nxdns import` from the pinned `package` reads `configFile` into a throwaway database during the build. A file that `import` rejects, because it does not parse, breaks a field rule or does not reconcile, fails `nixos-rebuild` with the binary's own reason, as in `FAIL rules[0].pattern: ...`, and nothing is deployed. `nxdns check` is not used, because it queries every upstream and the build sandbox has no network. So the build does not load the DoH and DoT certificate and key files, and does not reach the upstreams: a missing certificate still stops the service at start. The build also says nothing about the database already on the machine; see the rollback note below.
- **nxdns runs in file mode.** The service runs `nxdns run --config=/etc/nxdns/config.zon`: every start reconciles the database onto the file, and the web interface refuses configuration edits. Change the file and switch to change anything. A switch that changes only the configuration restarts the service.
- **The unit is the packaged one.** The package installs `deploy/systemd/nxdns.service` from the release tarball, with its sandboxing and its `RestartPreventExitStatus=2 64`, and the module adds a drop-in that changes only the command line and leaves `/etc/nxdns` to NixOS. `systemctl cat nxdns.service` shows both.
The module does not touch the machine's own resolver. If you point `networking.nameservers` at `127.0.0.1`, the machine loses name resolution whenever nxdns is down, including while you repair it; a resolver outside the machine avoids that.
**Rolling back.** A `nixos-rebuild --rollback` or an older generation puts an older binary in front of a database that the newer one may have migrated. That binary refuses the database with `SchemaTooNew`, exit 2, and the unit stops instead of restarting. [Upgrade](upgrade.md#what-happens-to-the-database) describes the recovery.
The package alone is also available, for a machine that runs nxdns some other way: `inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default` installs `bin/nxdns`, `lib/systemd/system/nxdns.service`, and `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary.
## 4. Let Renovate bump the tag
+11 -1
View File
@@ -4,7 +4,7 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ nixpkgs, ... }:
{ self, nixpkgs, ... }:
let
# `zig build cut` rewrites the lines between the delimiters by text replacement.
# BEGIN GENERATED BY zig build cut
@@ -46,6 +46,9 @@
runHook preInstall
install -Dm755 nxdns $out/bin/nxdns
install -Dm644 nxdns.service $out/lib/systemd/system/nxdns.service
substituteInPlace $out/lib/systemd/system/nxdns.service \
--replace-fail "ExecStart=/usr/local/bin/nxdns run" "ExecStart=$out/bin/nxdns run"
install -Dm644 LICENSE $out/share/doc/nxdns/LICENSE
install -Dm644 THIRD-PARTY-NOTICES $out/share/doc/nxdns/THIRD-PARTY-NOTICES
@@ -110,5 +113,12 @@
packages = nixpkgs.lib.genAttrs systems (system: {
default = package system;
});
nixosModules.default = import ./nix/module.nix { inherit self; };
checks.x86_64-linux = import ./nix/tests.nix {
inherit self;
pkgs = nixpkgs.legacyPackages.x86_64-linux;
};
};
}
+105
View File
@@ -0,0 +1,105 @@
# `services.nxdns`: the packaged unit, run in file mode against a configuration
# that the pinned binary accepted at build time.
#
# The unit is `deploy/systemd/nxdns.service` from the release tarball, installed
# by the package and picked up through `systemd.packages`. This module only adds
# a drop-in, so the hardening lives in that one file and a change to it reaches
# NixOS with the release that ships it.
{ self }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.nxdns;
# Nix treats the ZON as opaque data, so without this a configuration the
# pinned binary rejects evaluates, builds and deploys, and fails only when
# the service starts. `import` into a throwaway data directory is the offline
# validator: it shares the parser, the validator and the reconcile engine
# with `run`, and its delete gate is inert on an empty database. `check` is
# not usable here: it probes every upstream, and the build sandbox has no
# network.
validatedConfig =
pkgs.runCommand "nxdns-config.zon"
{
nativeBuildInputs = [ cfg.package ];
src = cfg.configFile;
}
''
mkdir -p "$TMPDIR/validate"
nxdns import --data-dir "$TMPDIR/validate" "$src"
cp "$src" "$out"
'';
in
{
options.services.nxdns = {
enable = lib.mkEnableOption "nxdns, a DNS sinkhole for a household network";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "nxdns.packages.\${pkgs.stdenv.hostPlatform.system}.default";
description = "The nxdns release to run. The default is the release of the flake revision this module comes from.";
};
configFile = lib.mkOption {
type = lib.types.path;
example = lib.literalExpression "./nxdns-config.zon";
description = ''
The nxdns configuration, in ZON. nxdns runs in file mode: every start
reconciles the database onto this file, and the web interface refuses
configuration edits. The build fails when `nxdns import` from
`package` rejects the file.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open port 53 over UDP and TCP. The web interface port and the DoH and DoT ports stay closed.";
};
};
config = lib.mkIf cfg.enable {
environment.etc."nxdns/config.zon".source = validatedConfig;
users.users.nxdns = {
isSystemUser = true;
group = "nxdns";
description = "nxdns DNS sinkhole";
};
users.groups.nxdns = { };
systemd.packages = [ cfg.package ];
systemd.services.nxdns = {
wantedBy = [ "multi-user.target" ];
# The unit file does not change when only the configuration does, so
# without this a configuration-only switch restarts nothing and the old
# configuration keeps serving with no error anywhere.
restartTriggers = [ validatedConfig ];
serviceConfig = {
# The empty entry clears the packaged ExecStart: a drop-in cannot
# replace it otherwise.
ExecStart = [
""
"${lib.getExe cfg.package} run --config=/etc/nxdns/config.zon"
];
# `environment.etc` creates /etc/nxdns and owns what is in it. A
# ConfigurationDirectory would make systemd adjust the same directory
# at every start.
ConfigurationDirectory = "";
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedUDPPorts = [ 53 ];
allowedTCPPorts = [ 53 ];
};
};
}
+97
View File
@@ -0,0 +1,97 @@
# The acceptance checks for `services.nxdns`. Neither runs in CI: the VM test
# needs KVM, and both need a nixpkgs closure the shared runner should not
# download on every push. AGENTS.md says when to run them.
{ self, pkgs }:
let
lib = pkgs.lib;
# A local record answers without any upstream, which the test VM cannot reach.
configWith =
address:
pkgs.writeText "nxdns-test.zon" ''
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "tls://192.0.2.1:853", .tls_name = "dns.example" } },
.local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "${address}" } },
}
'';
rejected = pkgs.writeText "nxdns-rejected.zon" ''
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "tls://192.0.2.1:853", .tls_name = "dns.example" } },
.rules = .{ .{ .group = "default", .pattern = "(", .kind = .regex, .action = .block } },
}
'';
machineWith = configFile: {
imports = [ self.nixosModules.default ];
services.nxdns = {
enable = true;
inherit configFile;
};
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "none";
fsType = "tmpfs";
};
system.stateVersion = lib.trivial.release;
};
in
{
nixos-module = pkgs.testers.runNixOSTest {
name = "nxdns-module";
nodes.machine =
{ pkgs, ... }:
{
imports = [ self.nixosModules.default ];
services.nxdns = {
enable = true;
configFile = configWith "192.0.2.5";
openFirewall = true;
};
environment.systemPackages = [ pkgs.dnsutils ];
specialisation.changed.configuration.services.nxdns.configFile = lib.mkForce (
configWith "192.0.2.6"
);
};
testScript = ''
machine.wait_for_unit("nxdns.service")
machine.wait_until_succeeds("dig +short @127.0.0.1 nas.lan A | grep -x 192.0.2.5")
# The packaged unit runs, with the module's drop-in on top of it.
unit = machine.succeed("systemctl cat nxdns.service")
assert "/lib/systemd/system/nxdns.service" in unit, unit
assert "ProtectSystem=strict" in unit, unit
assert "run --config=/etc/nxdns/config.zon" in unit, unit
machine.succeed("systemctl show -p User --value nxdns.service | grep -x nxdns")
machine.succeed("test -z \"$(systemctl show -p ConfigurationDirectory --value nxdns.service)\"")
machine.succeed("systemctl show -p ReadOnlyPaths --value nxdns.service | tr ' ' '\\n' | grep -Fx /etc/nxdns")
machine.succeed("stat -c %U /etc/nxdns | grep -x root")
machine.succeed("iptables -S nixos-fw | grep -- '-p udp .*--dport 53 '")
machine.succeed("iptables -S nixos-fw | grep -- '-p tcp .*--dport 53 '")
# A switch that changes only the configuration restarts the service.
before = machine.succeed("systemctl show -p InvocationID --value nxdns.service").strip()
machine.succeed("/run/current-system/specialisation/changed/bin/switch-to-configuration test")
machine.wait_for_unit("nxdns.service")
after = machine.succeed("systemctl show -p InvocationID --value nxdns.service").strip()
assert before != after, "a configuration-only switch did not restart nxdns"
machine.wait_until_succeeds("dig +short @127.0.0.1 nas.lan A | grep -x 192.0.2.6")
'';
};
# A configuration the pinned binary rejects must fail the system build, and
# the build log must carry the binary's own reason.
nixos-module-rejects-config =
let
system = pkgs.nixos (machineWith rejected);
failure = pkgs.testers.testBuildFailure system.config.environment.etc."nxdns/config.zon".source;
in
pkgs.runCommand "nxdns-module-rejects-config" { } ''
grep -F 'FAIL rules[0].pattern' ${failure}/testBuildFailure.log
touch "$out"
'';
}
+30
View File
@@ -0,0 +1,30 @@
# NixOS module: `services.nxdns`
Owner ruling (2026-10-20): ship `nixosModules.default` from the flake, with the options `enable`, `package`, `configFile` and `openFirewall`, file mode only, no `settings` attrset, and the shipped `nxdns.service` reused through `systemd.packages` plus a drop-in. The owner left Nix in CI, the VM test and a distinct exit code for `SchemaTooNew` to the implementer, within one constraint: the self-hosted Gitea runner (`../mial.net/manifests/gitea/`) is shared and must not be monopolized.
## Why
The flake already shipped a package and had one consumer, `rpi.mial.net`, which wrote its own module. Four parts of that module are not specific to the Pi, and two encode incidents: the build-time `nxdns import` validation, and `restartTriggers`, without which a configuration-only deploy restarted nothing (2026-08-16). A package with no service is half an install on NixOS.
## What ships
- `nix/module.nix`. `configFile` goes through `nxdns import --data-dir $TMPDIR/validate` from `package` in a `runCommand`, and the result is `/etc/nxdns/config.zon`. The `nxdns` system user and group. `systemd.packages = [ package ]`, and a drop-in that clears `ExecStart` and sets `run --config=/etc/nxdns/config.zon`, clears `ConfigurationDirectory` because `environment.etc` owns `/etc/nxdns`, adds `wantedBy` and `restartTriggers`. `openFirewall` opens 53/udp and 53/tcp and nothing else.
- The package installs the tarball's `nxdns.service` to `lib/systemd/system/` with `ExecStart` pointed at its own binary. `--replace-fail` makes a changed `ExecStart` line in the unit a package build failure, not a silent mismatch.
- Not shipped: database mode, a Nix-to-ZON renderer, and the Pi's choices about its own resolver, which stay in `rpi.mial.net`.
## Decisions left to the implementer
- **No Nix in CI.** No job installs Nix today. The checks need a nixpkgs closure and, for the VM test, KVM; downloading that on every push costs the shared runner disk and network, and the runner's cache already caused a disk-pressure eviction on 2026-08-23. The cut's existing `nix flake check --no-build` evaluates both checks at every release, which catches a module that no longer evaluates.
- **The VM test runs on the developer's host.** `checks.x86_64-linux.nixos-module` and `checks.x86_64-linux.nixos-module-rejects-config` are built locally, with KVM, after any change to the module, the checks, the package or the unit. AGENTS.md says so. Whether the runner node exposes `/dev/kvm` was not checked, and nothing here depends on it.
- **No new exit code.** `SchemaTooNew` is already in `config/faults.zig`, so `run` exits 2 for it and the unit's `RestartPreventExitStatus=2 64` stops instead of restart-looping (`src/app.zig` tests the mapping). A NixOS rollback onto a migrated database therefore stops the unit, and `docs/how-to/upgrade.md` describes the recovery. The exit-1 behaviour in the Pi module's notes dates from 0.0.2.
## Acceptance
- `nix flake check --no-build` passes.
- `nixos-module`: a VM with the module and `openFirewall` answers `nas.lan` from a local record; `systemctl cat` shows the packaged unit and the drop-in; the service runs as `nxdns`; the effective `ConfigurationDirectory` is empty, `ReadOnlyPaths` still holds `/etc/nxdns`, and `/etc/nxdns` belongs to root; port 53 is open over UDP and TCP; `switch-to-configuration test` into a specialisation that changes only `configFile` gives a new `InvocationID` and the new answer. Without `restartTriggers` the test fails on the restart assertion, and without the `ConfigurationDirectory` clear it fails on that one (each checked once by removing the line).
- `nixos-module-rejects-config`: a system build with an invalid regex rule fails, and its log carries `FAIL rules[0].pattern`.
- `docs/how-to/install-with-nix.md` documents the module, the options, the store-readability of `configFile`, and rollback.
## After the release
`rpi.mial.net` replaces its module with `services.nxdns` and keeps its Pi-only parts, through the rpi-nixos-iac peer.