milestone 14: build, package, sign and publish releases

This commit is contained in:
2026-08-08 12:38:29 +02:00
parent 6c507992e4
commit cdacc560b7
48 changed files with 7272 additions and 543 deletions
+5 -3
View File
@@ -23,11 +23,13 @@ can delete afterwards.
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 container
image and compose file.
- [how-to/upgrade.md](how-to/upgrade.md) — move to a new build without losing
- [how-to/install-with-docker.md](how-to/install-with-docker.md) — the published
container image and the compose file.
- [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.
+2
View File
@@ -16,6 +16,7 @@ pub const howto_measure_performance_md = @embedFile("how-to/measure-performance.
pub const howto_set_up_admin_authentication_md = @embedFile("how-to/set-up-admin-authentication.md");
pub const howto_troubleshoot_md = @embedFile("how-to/troubleshoot.md");
pub const howto_upgrade_md = @embedFile("how-to/upgrade.md");
pub const howto_verify_a_release_md = @embedFile("how-to/verify-a-release.md");
pub const Page = struct {
/// Repo-relative path, so a failing assertion names the file to edit.
@@ -38,6 +39,7 @@ pub const pages: []const Page = &.{
.{ .path = "docs/how-to/set-up-admin-authentication.md", .text = howto_set_up_admin_authentication_md },
.{ .path = "docs/how-to/troubleshoot.md", .text = howto_troubleshoot_md },
.{ .path = "docs/how-to/upgrade.md", .text = howto_upgrade_md },
.{ .path = "docs/how-to/verify-a-release.md", .text = howto_verify_a_release_md },
};
/// The pages that paste a transcript naming the running binary's version. Each
+60 -9
View File
@@ -16,7 +16,8 @@ PLAN §18 sets five:
- blocklist lookup p95 < 1 ms;
- cached response p95 < 5 ms;
- memory with ~1M blocked domains < 100 MiB;
- stripped static binary < 10 MiB per arch, < 15 MiB with the embedded frontend.
- stripped static binary 10,485,760 bytes per arch, 15,728,640 bytes with
the embedded frontend.
They are household-scale numbers, and they are deliberately unambitious. 100
qps is far more than a house generates; the point of the target is not speed
@@ -59,17 +60,38 @@ baseline for the machine development happens on, not a claim about the target
platform; a Cortex-A76 is far slower and those numbers do not transfer.
CI does gate on the one performance property that *is* deterministic: binary
size. The `cross` job strips the release binaries and asserts them under the
§18 budgets. Size is a function of the input, not of the runner's mood, so it
is exactly the kind of thing a shared runner can measure honestly.
size. The `package` job builds the release artifacts and `zig build verify-dist`
asserts both §18 budgets against them. Size is a function of the input, not of
the runner's mood, so it is exactly the kind of thing a shared runner can
measure honestly.
The budgets are asserted as exact byte counts, and the asset-free budget gets
its own build against a generated empty assets directory rather than against
`web/dist-placeholder`. The placeholder is not buildable by `dist` at all —
that is the guard against a release shipping a stub admin page — and letting it
back in through a size check would have defeated the guard for the sake of one
number.
## What the test suite is
The blocking CI (Gitea Actions, `.gitea/workflows/ci.yml`) runs five jobs, all
required: the Zig suite with `-Dintegration`; the same suite cross-built for
aarch64 and executed under qemu-user; the frontend (format, lint, typecheck,
121 vitest cases, build); the cross-build with the two stripped-size asserts;
and a Docker smoke run that boots the image and polls `/api/health`.
Every blocking check lives in `.gitea/workflows/gates.yml`, which is a
`workflow_call` workflow with nothing in it but jobs. `ci.yml` calls it on push
and pull request for `master`, and `release.yml` calls it before it builds
anything publishable. That shape exists for one reason: a check that lived in
`ci.yml` alone would be a check a release could skip.
Five jobs, all required:
- `test` — the Zig suite with `-Dintegration`.
- `test-aarch64` — the same suite cross-built for aarch64 and executed under
qemu-user, plain tier only.
- `frontend` — format, lint, typecheck, the vitest cases, build.
- `package``zig build dist` and `zig build verify-dist`, which is where the
size budgets, the ELF static-linkage assert and the archive layout checks
are.
- `container` — builds the image, asserts the binary inside it is byte-identical
to the one in the matching tarball, and smoke-tests it by booting the
container and polling `/api/health`.
The Zig suite has three tiers, gated by build flags:
@@ -159,3 +181,32 @@ Two honest gaps remain, stated so nobody has to rediscover them:
- There is no freshness check on `web/dist`. CI cannot embed a stale bundle,
because the jobs that pass `-Dweb-dist` rebuild the frontend immediately
beforehand. A local build can, and will do it without a warning.
## What a signed release does not prove either
The same distinction applies one level out, to the artifacts. A release is
signed, and the signature is worth having: it says the artifact came from this
project's pipeline and reached you unaltered. It does not say the binary was
built from the source in this repository, because the machine that ran the
build also held the signing key. An attacker with that machine produces
something that verifies cleanly and contains whatever they put in it.
The control that closes that gap is a reproducibility gate — an independent
build, in a different directory on a different machine, landing on the same
bytes. It does not exist. It is a recorded deferral (`specs/milestone-14.md`
ruling 12), not something nobody thought of, and until it exists no document
here describes the build as reproducible: nobody has measured whether it is.
The cheap inputs to reproducibility are already in place — `gzip -n`,
`--mtime=@0`, `LC_ALL=C`, `TZ=UTC`, exact Zig and Node pins — which makes the
gate cheap to add later and proves nothing on its own.
What the release pipeline is required to hold to is narrower: two runs of
`zig build dist` on the same commit **in the same directory** produce
byte-identical tarballs. Same-directory determinism is a much weaker property
than reproducibility, and conflating the two is exactly the kind of claim this
page exists to refuse.
[Verify a release](../how-to/verify-a-release.md) states the same limits where
an operator will actually meet them, and gives the rebuild-and-compare recipe
with the caveat that a differing hash is not evidence of tampering while this
gap is open.
+158 -57
View File
@@ -1,53 +1,85 @@
# Install nxdns with Docker
Builds the nxdns image and runs it with Docker Compose. At the end a container
Runs the published nxdns image with Docker Compose. At the end a container
answers DNS on port 53 and keeps its data in a named volume.
The image is multi-architecture — `linux/amd64` and `linux/arm64` — so the same
tag works on a PC and on a Raspberry Pi 5. Building the image yourself is still
supported and is the last section of this page.
For what each configuration field means, see
[the configuration reference](../reference/configuration.md).
> Verification: every command on this page was run on the machine that wrote
> it, with three exceptions marked below — the `chown` to uid 65532 needs root,
> the arm64 image was built but not run, and pushing to a registry needs
> credentials. One command was run in altered form: host port 8080 was occupied
> here, so the run and the two verification commands in step 3 were executed
> with the host side of the port mappings moved to 25353 and 28088 rather than
> the 53 and 8080 printed below. The container side was unchanged. See the note
> in step 3.
> Verification: the seed-file failure modes, the run and the two checks in
> step 3 were run on the machine that wrote this page, against an image built
> from this checkout rather than pulled from the registry — no release is
> published yet, so nothing on this page could be run against a pulled image,
> and step 1 could not be run at all. One command was run in altered form:
> host port 8080 was occupied here, so the run and the verification commands
> in step 3 were executed with the host side of the port mappings moved to
> 25353 and 28088 rather than the 53 and 8080 printed below. The container
> side was unchanged. See the note in step 3. The `chown` to uid 65532 needs
> root and was not run.
## 1. Build the image
The Dockerfile does not compile anything. It assembles a filesystem around a
binary you build first, so build the admin interface and the binaries from the
repository root:
## 1. Pull and verify the image
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
docker build -t nxdns -f deploy/docker/Dockerfile .
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
docker pull git.mial.net/mokhtar/nxdns:$VERSION
```
Build `web/dist` every time, before the binary. A stale bundle is embedded
silently and ships an admin interface that does not match its API.
Pin a version. `:latest` exists and moves, which is what you want when you are
trying it out and not what you want on a machine your household's DNS depends
on. The lookup above asks the server for the current release rather than
hardcoding a number that goes stale one release later — Gitea redirects
`releases/latest` to the newest published release's tag page. To take a
particular version instead, set `VERSION=<version>` yourself.
The context has to be the repository root, because the Dockerfile copies
`zig-out/cross`. The result is a `scratch` image holding the binary, a CA
bundle and two empty directories — 28.2 MB here.
Compose runs the same build with the right context:
Verify what you pulled before you run it. The release publishes an
`IMAGE-DIGEST.txt` asset naming the digest of the image index, and that file is
covered by the signed `SHA256SUMS.txt`:
```sh
docker compose -f deploy/docker/compose.yaml build
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
--format '{{.Manifest.Digest}}'
cut -d@ -f2 IMAGE-DIGEST.txt
```
Every block on this page runs from the repository root, and none of them change
directory, so they can be pasted in order. `-f` is what makes that work:
Compose resolves the relative paths inside `compose.yaml` — the build context,
the `etc-nxdns` bind mount — against the directory holding the file, not
against your shell, and it takes the project name `docker` from that directory
either way, which is why the container is `docker-nxdns-1`.
The last two have to print the same string — `IMAGE-DIGEST.txt` holds a whole
pinned reference, `name:tag@sha256:…`, so the `cut` is what reduces it to the
digest `imagetools` prints. [Verify a release](verify-a-release.md)
covers the key, the fingerprint, every failure message, and what the signature
does and does not prove.
## 2. Write the seed configuration
> Not verified on this host: no image and no release are published yet, so
> `docker pull` and every URL here fail today, and the `releases/latest` lookup
> returns 404 and leaves `VERSION` empty. That lookup was run against
> `gitea.com/gitea/tea` on Gitea `1.27.0+dev` and printed `0.15.1`. The
> `docker buildx imagetools inspect --format` shape was run here against
> `alpine:3.22` on Docker Hub and printed that image's index digest.
## 2. Get the compose file and write the seed configuration
Every path on this page is relative to a checkout of the repository, because
that is how it was verified. Running the published image needs no checkout,
though — one file is enough. Fetch it for the version you pulled and work in
its directory instead, dropping `deploy/docker/` from the paths below:
```sh
mkdir -p ~/nxdns && cd ~/nxdns
curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
```
> Not verified against nxdns: there is no tag yet. The Gitea raw-file URL shape
> `<repo>/raw/tag/<tag>/<path>` was run here against `gitea.com/gitea/tea` on
> Gitea `1.27.0+dev` and returned the file with a 200.
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
it and put the seed file in it:
@@ -75,8 +107,8 @@ followed by `nxdns run failed: NoUsableUpstreams`.
A file that is present but rejected is a different failure with the same exit
code. No `default` group, no enabled upstream, a syntax error — `run` prints the
diagnostic and exits 2 as well. Both were run here against this image. A seed
file whose only group was named `other`:
diagnostic and exits 2 as well. Both were run here against a locally built
image. A seed file whose only group was named `other`:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
@@ -115,10 +147,28 @@ chmod 0600 deploy/docker/etc-nxdns/config.zon
## 3. Run it
```sh
docker compose -f deploy/docker/compose.yaml up -d
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
docker compose -f deploy/docker/compose.yaml logs -f
```
`compose.yaml` reads the image from two variables:
`${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set
`NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else
entirely, which is what the build-from-source section at the bottom does.
> Verified on this host with `docker compose -f deploy/docker/compose.yaml
> config`, which resolves the variables without contacting a registry: no
> variables gives `git.mial.net/mokhtar/nxdns:latest`, `NXDNS_VERSION=0.0.1`
> gives `git.mial.net/mokhtar/nxdns:0.0.1`, and `NXDNS_IMAGE=nxdns` gives
> `nxdns`.
Every block on this page runs from the repository root, and none of them change
directory, so they can be pasted in order. `-f` is what makes that work:
Compose resolves the relative paths inside `compose.yaml` — the `etc-nxdns`
bind mount — against the directory holding the file, not against your shell,
and it takes the project name `docker` from that directory either way, which is
why the container is `docker-nxdns-1`.
A healthy first start logs the seeding and the bound sockets:
```
@@ -141,7 +191,8 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
> ports `dig` returned the A records for `example.com` and `curl` returned 200.
> `docker compose up -d` fails with
> `failed to bind host port 0.0.0.0:8080/tcp: address already in use` when a
> host port is occupied; free the port or edit the `ports:` list.
> host port is occupied; free the port or edit the `ports:` list. The image
> under test was built locally, not pulled: there is nothing published to pull.
The compose file publishes 53/udp, 53/tcp and 8080, keeps `/var/lib/nxdns` in
the named volume `nxdns-data`, and sets the per-namespace sysctl
@@ -157,31 +208,81 @@ container, the container's startup lookups depend on the service that is trying
to start. Point LAN clients at nxdns; leave the container's host on its own
resolver.
## Build for a Raspberry Pi 5
## Raspberry Pi 5
The Dockerfile maps buildx's `TARGETARCH` onto the cross-target directory, so
the aarch64 image comes from the same `zig-out/cross` tree with no second
compile. Under the legacy builder, where `TARGETARCH` is empty, the Dockerfile
falls back to the build host's `uname -m`, so a plain `docker build` on the Pi
itself also selects the aarch64 binary:
Nothing changes. The published tag is a multi-architecture index, so
`docker pull` on the Pi selects the `linux/arm64` image on its own. The
platform list is one of the things
[Verify a release](verify-a-release.md) has you check.
To pull the arm64 image from an x86_64 machine — to inspect it, or to save and
copy it — name the platform:
```sh
docker pull --platform linux/arm64 git.mial.net/mokhtar/nxdns:$VERSION
```
> Not verified on this host: nothing is published to pull, and this host is
> x86_64 with no emulation, so an arm64 image could not be started here even
> if it were.
## Build the image from source instead
The Dockerfile does not compile anything. It assembles a filesystem around
binaries you build first, so build the admin interface and the release
artifacts from the repository root:
```sh
(cd web && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
```
Take the version from `build.zig.zon` rather than inventing one: `verify-dist`
asserts the two agree, so a made-up string builds but fails verification.
BuildKit is required — the Dockerfile pins its builder stage to
`$BUILDPLATFORM`, which the classic builder does not define.
Build `web/dist` every time, before the binaries. A stale bundle is embedded
silently and ships an admin interface that does not match its API — which is
why `dist` refuses to build against the `web/dist-placeholder` default at all.
The context has to be the repository root, because the Dockerfile copies
`zig-out/dist/bin` and `zig-out/dist/stage`. The result is a `scratch` image
holding the binary, a CA bundle, `/LICENSE`, `/THIRD-PARTY-NOTICES` and two
empty directories.
Run that image instead of the published one by naming it:
```sh
NXDNS_IMAGE=nxdns docker compose -f deploy/docker/compose.yaml up -d
```
For an arm64 image on an x86_64 machine, use buildx. The Dockerfile's builder
stage is pinned to `$BUILDPLATFORM` and only copies files, so no emulation is
involved:
```sh
docker buildx build --platform linux/arm64 -t nxdns:arm64 -f deploy/docker/Dockerfile .
```
This was run here and completed; add `--push` or `--load` to keep the result,
since the default buildx driver leaves it in the build cache.
Add `--push` or `--load` to keep the result; the default buildx driver leaves
it in the build cache.
> Not verified on this host: the arm64 image was not started. Running it needs
> an aarch64 machine or qemu binfmt emulation, neither of which is available
> here.
## Publish the image to a registry
There is no registry push in CI on purpose: credentials and the choice of
registry are infrastructure decisions, not this repository's. Publish by hand
with `docker login <registry>`, then `docker tag nxdns
<registry>/<owner>/nxdns:<tag>`, then `docker push
<registry>/<owner>/nxdns:<tag>`.
> Not verified on this host: pushing needs credentials for a registry.
> Verified on this host, except the two buildx lines. `zig build dist` was run
> to completion with the version read out of `build.zig.zon` and exited 0, and
> the `DOCKER_BUILDKIT=1 docker build` above was then run against that
> `zig-out/dist` tree and exited 0. The binary copied out of the resulting
> image with `docker cp` hashed identically to
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns`. The
> `NXDNS_IMAGE=nxdns docker compose ... up -d` line was verified as described in
> step 3, with the host ports moved. `docker buildx build --platform
> linux/arm64` was run here too and exited 0 on an x86_64 host with no
> emulation available — the builder stage is pinned to `$BUILDPLATFORM` and the
> final stage is `FROM scratch`, so nothing arm64 ever executes during the
> build. It printed the driver's `No output specified` warning, which is the
> reason the paragraph above tells you to add `--push` or `--load`. See
> [Install with systemd](install-with-systemd.md#build-from-source-instead) for
> the `dist` and `verify-dist` detail.
+164 -44
View File
@@ -4,58 +4,105 @@ Installs nxdns as a system service on a Linux host with systemd, including a
Raspberry Pi 5. At the end the service answers DNS on port 53 and starts on
boot.
The normal path is to download a released tarball, verify it, and install what
is inside it. Building from source is still supported and is the last section
of this page.
For what each flag does, see [the CLI reference](../reference/cli.md); for what
each configuration field means, see
[the configuration reference](../reference/configuration.md).
> Verification: the build steps and `systemd-analyze verify` were run on the
> machine that wrote this page. `nxdns check` and `nxdns run` were run there
> too, but against a scratch `--data-dir` and `--config` on an unprivileged
> port, because that machine is not a deploy target and has no `/etc/nxdns`,
> no `/var/lib/nxdns` and no root. The steps that need root on a target host —
> `install`, `systemd-sysusers`, `systemctl` — were not run; they are marked
> where they appear.
> Verification: `systemd-analyze verify` was run on the machine that wrote this
> page. `nxdns check`, `nxdns import`, `nxdns export` and `nxdns run` were run
> there too, but against a scratch `--data-dir` and `--config` on an
> unprivileged port, because that machine is not a deploy target and has no
> `/etc/nxdns`, no `/var/lib/nxdns` and no root. The steps that need root on a
> target host — `install`, `systemd-sysusers`, `systemctl` — were not run; they
> are marked where they appear.
>
> The download in step 1 could not be run at all: this repository has no tags
> and no published release, so every release URL on this page is a 404 today.
> Its commands are the ones [Verify a release](verify-a-release.md) covers in
> full, and the URL shapes — including the `releases/latest` redirect the
> version is read from — were probed there against `gitea.com`, a public
> instance running the same Gitea series.
>
> The `zig build dist` and `zig build verify-dist` blocks in the last section
> were run here, both to completion and both exiting 0; that section carries
> the detail.
## 1. Build the binary
## 1. Download and verify
Requires Zig 0.16.0 and Node.js. From the repository root:
Two static musl tarballs are published per release, one per architecture. Pick
`x86_64-linux-musl` for a normal PC or server and `aarch64-linux-musl` for a
Raspberry Pi 5.
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
```
The first command builds the admin interface into `web/dist`; the second
embeds that directory in the binary. Build the interface every time, before the
binary: a stale `web/dist` ships an admin UI that does not match the API it
talks to.
The first line asks the server which release is current instead of hardcoding a
number that goes stale one release later — Gitea redirects `releases/latest` to
the newest published release's tag page. To install a particular version
instead, set `VERSION=<version>` yourself with the one you want; the tarball
filenames carry the version either way, so there is no version-free download
URL for them.
Two static musl binaries come out, one per deploy target:
Verify before you extract. The signature is over `SHA256SUMS.txt`, and
`SHA256SUMS.txt` is over the tarballs:
- `zig-out/cross/x86_64-linux-musl/nxdns`
- `zig-out/cross/aarch64-linux-musl/nxdns`
```sh
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
tar -xzf "nxdns-$VERSION-x86_64-linux-musl.tar.gz"
```
Both are statically linked and need nothing installed on the target host.
[Verify a release](verify-a-release.md) has the whole procedure: where the
public key comes from, what fingerprint to expect, what each failure means, and
what the signature does and does not prove. Read it once before your first
install.
The extracted directory `nxdns-$VERSION-x86_64-linux-musl/` holds everything
this page installs:
| File | What it is |
| --- | --- |
| `nxdns` | The static binary, mode 0755 |
| `nxdns.service` | The systemd unit |
| `nxdns.conf` | The sysusers fragment that creates the `nxdns` user |
| `LICENSE` | EUPL-1.2 |
| `THIRD-PARTY-NOTICES` | Licences of everything compiled or bundled in |
| `INSTALL.md` | A short version of this page |
> Not verified on this host: no release exists yet, so none of these commands
> could be run against one — the `releases/latest` lookup returns 404 for this
> repository and leaves `VERSION` empty. The same lookup was run against
> `gitea.com/gitea/tea` on Gitea `1.27.0+dev` and printed `0.15.1`.
## 2. Copy the files to the target
```sh
scp zig-out/cross/x86_64-linux-musl/nxdns target:/tmp/nxdns
scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf target:/tmp/
cd "nxdns-$VERSION-x86_64-linux-musl"
scp nxdns nxdns.service nxdns.conf target:/tmp/
```
For a Raspberry Pi 5, copy `zig-out/cross/aarch64-linux-musl/nxdns` instead —
see [Raspberry Pi 5](#raspberry-pi-5) below.
For a Raspberry Pi 5, extract the `aarch64-linux-musl` tarball instead — see
[Raspberry Pi 5](#raspberry-pi-5) below.
> Not verified on this host: `target` is a placeholder for your server's
> hostname, and the machine that wrote this page has no second host to copy to.
> What was verified is that both source paths exist after step 1 and that the
> aarch64 file is a statically linked aarch64 ELF executable.
> hostname, and the machine that wrote this page has no second host to copy
> to.
Before copying, you can confirm the unit file parses:
```sh
systemd-analyze verify deploy/systemd/nxdns.service
systemd-analyze verify nxdns.service
```
Off the target host this prints one complaint and exits 1:
@@ -68,6 +115,9 @@ That is the ExecStart path check finding no binary yet. Any other message is a
real problem with the unit. On the target, after step 3, the same command
should print nothing.
> Verified on this host against `deploy/systemd/nxdns.service` in a checkout,
> which is the same file the tarball ships — the path is the only difference.
## 3. Install the binary, the user and the unit
Run as root on the target:
@@ -75,7 +125,7 @@ Run as root on the target:
```sh
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
install -m 0644 /tmp/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
install -m 0644 /tmp/nxdns.conf /usr/lib/sysusers.d/nxdns.conf
systemd-sysusers
install -m 0644 /tmp/nxdns.service /etc/systemd/system/nxdns.service
@@ -84,6 +134,9 @@ systemctl daemon-reload
mkdir -p -m 0755 /etc/nxdns
```
The sysusers fragment ships under the name it is installed as, so there is no
rename to get wrong.
> Not verified on this host: these commands need root on a target machine. The
> files they install were read at HEAD and the unit was checked with
> `systemd-analyze verify`.
@@ -236,27 +289,94 @@ availability and disk state without a login.
## Raspberry Pi 5
The Pi 5 is aarch64. Nothing about the procedure changes except which binary
you copy — the cross build needs no toolchain on the Pi and no toolchain beyond
Zig on the build machine:
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball
you take:
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
scp zig-out/cross/aarch64-linux-musl/nxdns pi:/tmp/nxdns
scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf pi:/tmp/
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-aarch64-linux-musl.tar.gz"
sha256sum -c --ignore-missing SHA256SUMS.txt
tar -xzf "nxdns-$VERSION-aarch64-linux-musl.tar.gz"
cd "nxdns-$VERSION-aarch64-linux-musl"
scp nxdns nxdns.service nxdns.conf pi:/tmp/
```
Then follow steps 3 to 6 on the Pi.
The build was run on the machine that wrote this page and
`zig-out/cross/aarch64-linux-musl/nxdns` is a statically linked aarch64 ELF
executable.
> Not verified on this host: no release exists to download, `pi` is a
> placeholder for your Pi's hostname, and this page was written on an x86_64
> machine with no Pi attached.
> Not verified on this host: the two `scp` lines. `pi` is a placeholder for
> your Pi's hostname, and this page was written on an x86_64 machine with no Pi
> attached. The build steps above it were run; the copy was not.
## Build from source instead
> Not verified on this host: the aarch64 binary was not executed. This host is
> x86_64 and has no `qemu-aarch64` to run it under. Running it needs a
> Raspberry Pi 5 or another aarch64 machine.
You do not need this to install nxdns, and it gets you a binary nobody has
signed. It is here for two cases: you want to run something other than a
tagged release, or you want to build the release yourself and compare it
against the published one. For the second case, follow
[Verify a release](verify-a-release.md) rather than this section — it says what
the comparison is and is not worth.
Requires Zig 0.16.0 and Node.js. From the repository root:
```sh
(cd web && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
```
The first command builds the admin interface into `web/dist`; the last one
embeds that directory in the binary. Build the interface every time, before the
binary: a stale `web/dist` ships an admin UI that does not match the API it
talks to. `dist` refuses to run against the `web/dist-placeholder` default for
exactly that reason, so there is no way to skip it by accident.
`-Dversion-string` is required and has no default. It is what `nxdns version`
prints. Take it from `build.zig.zon` rather than inventing one: `verify-dist`
asserts that the version under build equals `.version` there, so a made-up
string like `0.0.0-local` builds but then fails verification. `-Dgit-commit`
is what distinguishes your build from the published one of the same version.
What comes out under `zig-out/dist/` is the same set a release publishes,
minus the signature and the image digest:
- `bin/<triple>/nxdns` — the stripped static binary, one per target
- `stage/nxdns-<version>-<triple>/` — the staged payload, one per target
- `nxdns-<version>-<triple>.tar.gz` — one tarball per target
- `SHA256SUMS` — the two tarball hashes. The release publishes this as
`SHA256SUMS.txt`, with a third line for the image digest appended
The two targets are `x86_64-linux-musl` and `aarch64-linux-musl`. Both binaries
are statically linked and need nothing installed on the target host.
Check the result the same way the release pipeline does:
```sh
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
```
`verify-dist` extracts each archive and asserts the ELF is static and within
the size budget, that the layout and file modes are exactly what step 1 lists,
and that `nxdns version` prints what was built. It exits non-zero on any
failure.
> Verified on this host: `zig build dist` and `zig build verify-dist` were both
> run to completion with the version taken from `build.zig.zon`. `dist`
> produced the two tarballs, `SHA256SUMS` and the staged payloads described
> above; `verify-dist` exited 0 with every check passing and the aarch64
> `nxdns version` check skipped for want of `-fqemu`. Passing a made-up
> `-Dversion-string=0.0.0-local` was also run: `dist` succeeded and
> `verify-dist` then failed with
> `FAIL zon-version: build.zig.zon says '0.0.1', the build says '0.0.0-local'`,
> which is why this section reads the version out of `build.zig.zon`.
From here, join the page at step 2 with the staged directory in place of the
extracted one:
```sh
cd "zig-out/dist/stage/nxdns-$VERSION-x86_64-linux-musl"
scp nxdns nxdns.service nxdns.conf target:/tmp/
```
The aarch64 binary is built by the same command and needs no toolchain on the
Pi.
+106 -21
View File
@@ -1,7 +1,11 @@
# Upgrade nxdns
Replaces a running nxdns with a newer build without losing its configuration.
The database is migrated in place on the first start of the new binary.
Replaces a running nxdns with a newer release without losing its
configuration. The database is migrated in place on the first start of the new
binary.
The normal path is to download the new release, verify it, and swap the binary.
Upgrading a build you made yourself is the last section of this page.
> Verification: the export, the migration behaviour and the `version`/`check`
> steps below were run on the machine that wrote this page, against a
@@ -11,6 +15,11 @@ The database is migrated in place on the first start of the new binary.
> have (`/var/lib/nxdns`, `/some/backup`, a `target` host), and every block
> where the substitution matters, or which was not run at all, carries its own
> note. Nothing here was verified except where a note says so.
>
> Step 2 could not be run at all: no nxdns release is published yet, so every
> release URL and the `docker compose pull` on this page fail today. The URL
> shapes and the verification commands are covered by
> [Verify a release](verify-a-release.md), which says what was probed and how.
## 1. Take an export first
@@ -46,32 +55,60 @@ The file is written atomically at mode 0600 and carries
[Back up and restore](back-up-and-restore.md) for the full backup story. The
query log is deliberately not part of it.
## 2. Build the new binary
## 2. Download and verify the new release
Read the release notes for the version you are moving to before you take it —
the `CHANGELOG.md` section for that version is the release body.
```sh
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
tar -xzf "nxdns-$VERSION-x86_64-linux-musl.tar.gz"
```
Rebuild `web/dist` before the binary on every upgrade. The admin interface is
embedded at build time, and an old bundle against a new API is a broken
settings page.
The first line asks the server which release is current, so this block does not
carry a version number that goes stale — Gitea redirects `releases/latest` to
the newest published release's tag page. To move to a particular version rather
than the newest, set `VERSION=<version>` yourself. Check it against what you are
running (`nxdns version`) before you download anything.
Take `aarch64-linux-musl` for a Raspberry Pi 5. Verify every time, not only on
the first install — an upgrade is a fresh download of a fresh artifact.
[Verify a release](verify-a-release.md) is the full procedure.
Under Docker there is nothing to download: step 3 pulls the image, and the
`IMAGE-DIGEST.txt` asset is what you verify instead.
> Not verified on this host: no release exists yet, so the `releases/latest`
> lookup returns 404 and leaves `VERSION` empty, and every `curl` below it is a
> 404 too. The lookup form was run against `gitea.com/gitea/tea` on Gitea
> `1.27.0+dev` and printed `0.15.1`.
## 3. Replace the binary
### systemd
Step 2 leaves the new binary under `zig-out/cross`, one per target. Copy the
one that matches the host — `aarch64-linux-musl` for a Raspberry Pi 5:
Step 2 leaves the new binary in the extracted directory. Copy the one that
matches the host — `aarch64-linux-musl` for a Raspberry Pi 5:
```sh
scp zig-out/cross/x86_64-linux-musl/nxdns target:/tmp/nxdns
scp "nxdns-$VERSION-x86_64-linux-musl/nxdns" target:/tmp/nxdns
```
> Not run on this host: `target` is a placeholder for the machine running
> nxdns, and this host has no such second machine to copy to. What exists here
> is the local half — `zig build cross` produced
> `zig-out/cross/x86_64-linux-musl/nxdns`.
> nxdns, and this host has no such second machine to copy to. There is also no
> release to have extracted.
The tarball also carries `nxdns.service` and `nxdns.conf`. An upgrade does not
normally reinstall them, but compare them against what is on the target when
the release notes say the unit changed.
Then, as root on the target:
@@ -90,19 +127,23 @@ journalctl -u nxdns -f
### Docker
```sh
cd deploy/docker
docker compose build
docker compose up -d
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml pull
NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
```
Compose recreates the container against the same `nxdns-data` volume. The seed
file in `etc-nxdns` is not read again; the database in the volume is the
configuration.
> Verified on this host for the first two lines: `docker compose config -q`
> exited 0, and `docker compose build` finished with `Image nxdns Built`.
> `docker compose up -d` was not run — it publishes host ports 53/udp, 53/tcp
> and 8080, which this workstation is not a deploy target for.
Set `NXDNS_VERSION` on both lines, or export it. Without it the compose file
falls back to `:latest`, and `pull` and `up` could then land on different
images if a release happens between them.
> Not run on this host: `pull` needs a published image, and there is none.
> What was run is `docker compose -f deploy/docker/compose.yaml config`, which
> resolves the variables without contacting a registry: `NXDNS_VERSION=0.0.1`
> gave `image: git.mial.net/mokhtar/nxdns:0.0.1`, and no variable at all gave
> `:latest`.
## 4. Confirm the upgrade
@@ -234,3 +275,47 @@ refusal on their own.
>
> The `systemctl stop`/`start` lines around them need root and an installed
> service and were not run; `$EDITOR` is yours to run.
## Upgrading to a build of your own
If you are running something you built rather than a release, step 2 is a
build instead of a download:
```sh
(cd web && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
```
Rebuild `web/dist` before the binary on every upgrade. The admin interface is
embedded at build time, and an old bundle against a new API is a broken
settings page. `dist` refuses the `web/dist-placeholder` default outright, so
the only way to ship a stale bundle is to leave an old `web/dist` in place.
The staged payload for each target is under
`zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there
with that path in place of the extracted one. The version string has to equal
`.version` in `build.zig.zon``verify-dist` asserts it, so a made-up one
builds and then fails verification. What tells your build apart from the
published release of the same version is `-Dgit-commit`, which `nxdns version`
prints beside the version.
Under Docker, build the image and name it instead of pulling:
```sh
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
NXDNS_IMAGE=nxdns docker compose -f deploy/docker/compose.yaml up -d
```
See [Install with Docker](install-with-docker.md) for what that build needs.
> Verified on this host for the two build commands in this section — the
> `zig build dist` block above and the `docker build` here. `dist` was run to
> completion with the version read out of `build.zig.zon` and exited 0, and the
> image was built from the resulting `zig-out/dist` tree, also exiting 0. The
> `docker compose ... up -d` line was not run in this round — the run itself is
> covered in [Install with Docker](install-with-docker.md#3-run-it), where a
> host port had to be moved to do it. See
> [Install with systemd](install-with-systemd.md#build-from-source-instead) for
> the `dist` and `verify-dist` detail.
+468
View File
@@ -0,0 +1,468 @@
# Verify a release
Checks that a downloaded nxdns release is the one the project published and
that it arrived intact. It also gives the recipe for rebuilding the same
version from source, and says plainly what that does and does not settle.
Do this before you run the binary, not after. The whole point of the checksum
file is that it is signed, so a tampered mirror cannot hand you a matching
tarball and a matching checksum at the same time.
> Verification: no nxdns release exists yet. The repository has no tags, no
> release page and no pushed image, so nothing on this page could be run against
> a real release asset and no command here was pointed at
> `git.mial.net/mokhtar/nxdns` with any expectation of success. Substitutes were
> used, and every block says which one applies to it.
>
> The URL shapes were probed against `gitea.com`, a public instance of the same
> Gitea series running `1.27.0+dev-652-g0571722545`, using `gitea/tea`, which
> does have releases. `git.mial.net` reports `1.27.1`, and its
> `/mokhtar/nxdns/releases/latest` answers 404 — no release to redirect to. On
> `gitea/tea`, `releases/latest` answered 303 to the tag page of `v0.15.1`;
> `releases/download/v0.15.1/checksums.txt` and
> `releases/download/latest/checksums.txt` both answered 303 to the same stored
> object and delivered the same 1,842-byte file under `-L`;
> `releases/latest/download/checksums.txt` — GitHub's spelling — answered 404.
>
> The `gpg --verify` and `sha256sum -c` blocks were run on this host against
> stand-in files: two random-byte files named like the release tarballs, an
> `IMAGE-DIGEST.txt` holding one image reference, and a `SHA256SUMS.txt`
> computed over the three, signed by a **throwaway demonstration key generated
> for this page**. That key has the shape the real one will have — an ed25519
> primary key plus a separate ed25519 signing subkey, with the signature made by
> the subkey — so the `gpg --verify` output on this page has the two-fingerprint
> structure a subkey-signed release produces. The fingerprints printed in those
> transcripts are the throwaway key's, they are not the project's, and they will
> not match anything you download. The only edit to that run's output is the
> version in every filename, which became `<version>`.
>
> The container blocks were not run against nxdns — there is no published image.
> The two `docker buildx imagetools inspect --format` shapes were run here
> against `alpine:3.22` on Docker Hub, the base this project's builder stage
> pins; the digest form printed
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` and
> the platform form printed a list. The `docker create`/`docker cp` comparison
> was run against an image built from this checkout rather than a pulled one.
## What a release contains
Five assets, on the release page at
`https://git.mial.net/mokhtar/nxdns/releases`:
| Asset | What it is |
| --- | --- |
| `nxdns-<version>-x86_64-linux-musl.tar.gz` | The x86_64 tarball |
| `nxdns-<version>-aarch64-linux-musl.tar.gz` | The aarch64 tarball, for a Raspberry Pi 5 |
| `SHA256SUMS.txt` | One `sha256sum` line each for the two tarballs and for `IMAGE-DIGEST.txt` |
| `SHA256SUMS.txt.asc` | A detached OpenPGP signature over `SHA256SUMS.txt` |
| `IMAGE-DIGEST.txt` | The container image reference this version pushed, pinned by digest |
Each tarball holds one top-level directory, `nxdns-<version>-<triple>/`, with
six files in it: the `nxdns` binary at mode 0755, and `nxdns.service`,
`nxdns.conf`, `LICENSE`, `THIRD-PARTY-NOTICES` and `INSTALL.md` at 0644.
`SHA256SUMS.txt` covers `IMAGE-DIGEST.txt` rather than the image, because the
image digest does not exist until the push has happened and cannot be computed
by the build. Signing the file that names the digest gets you the same
guarantee in one signature.
The `.txt` on three of the five names is not decoration. Gitea decides what an
attachment may be by its file extension, and whether it accepts an
extensionless upload at all is untested against this instance, so the release
uses names it is known to accept. On disk, `zig build dist` still writes a file
called `SHA256SUMS`; the release job copies it to `SHA256SUMS.txt` and appends
the image line before signing.
## 1. Pick a version
Every URL below takes the version from one shell variable. Ask the server
rather than typing a number that goes stale: Gitea redirects `releases/latest`
to the tag page of the newest published release — newest by publication time,
and drafts and pre-releases are excluded.
```sh
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
echo "$VERSION"
```
To take a particular version instead, set it yourself — substitute the one you
want for the placeholder:
```sh
VERSION=<version>
```
> Not verified against nxdns: there is no release to redirect to, so the first
> block prints an empty line here and every URL built from it is a 404. The
> exact two-command form was run against `gitea.com/gitea/tea`, a public
> repository on Gitea `1.27.0+dev` that does have releases, and printed
> `0.15.1`.
Pin the version in anything you script or automate. `latest` is convenient for
a person at a terminal and a liability in a machine that upgrades itself.
## 2. Download
```sh
mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"
```
`-L` is not optional: Gitea answers an asset URL with a 303 to wherever the
attachment is actually stored.
For the aarch64 tarball, or for both, swap or add the filename:
```sh
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-aarch64-linux-musl.tar.gz"
```
Gitea also accepts the literal word `latest` in place of the tag, so the three
assets whose names carry no version can be fetched without one:
```sh
curl -fLO "$BASE/releases/download/latest/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/latest/SHA256SUMS.txt.asc"
curl -fLO "$BASE/releases/download/latest/IMAGE-DIGEST.txt"
```
That is the Gitea spelling, and it is not GitHub's. `releases/latest/download/`
— the form GitHub uses — is a 404 on Gitea; the alias goes in the tag
position, as `releases/download/latest/`. The tarball filenames contain the
version, so this alias never saves you from knowing it for those two.
> Not verified against nxdns: no release, so every URL above is a 404 today.
> Both URL forms, including the 404 for GitHub's spelling, were exercised
> against `gitea.com/gitea/tea` on Gitea `1.27.0+dev`; the versioned path and
> the `latest` alias each answered 303 to the same stored object and delivered
> the same 1,842-byte `checksums.txt` when the redirect was followed.
## 3. Check the signature over `SHA256SUMS.txt`
Get the public key first. It is a signing subkey of the key that signs every
commit in this repository, so you can confirm the fingerprint against a clone
you already have with `git log --show-signature` or `git verify-tag v$VERSION`:
```
A2061F6AB24DF2C0E92346FD1509B54946D08A95
```
```sh
curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/A2061F6AB24DF2C0E92346FD1509B54946D08A95 |
gpg --import
```
> Not verified: the key is not published yet. Run on this host, that URL
> returned 404, and so did the `by-email` lookup for the same address. The
> endpoint itself is live: the same `by-fingerprint` path returned 200 for an
> unrelated key that is on keys.openpgp.org. Until this key is published there,
> get it from a source you can check some other way.
Then verify:
```sh
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
```
```
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
gpg: Good signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: 6643 13AA F527 DDAE 1C1E 516C A36F F8DA 4E6C 1C07
Subkey fingerprint: 9D1E A241 DAEA 89E0 9381 A21B DC27 E8A3 D53C 32D6
```
**Those two fingerprints and that user id belong to a throwaway key generated
to produce this transcript.** They are not the project's, and what you see will
carry the project's uid and the fingerprint in this page instead. The
*structure* is what to read: three lines, not one. `using EDDSA key` and
`Subkey fingerprint` name the signing subkey that actually made the signature;
`Primary key fingerprint` names the certificate it hangs off, and that is the
one published above.
Exit status 0, and `Good signature`. That warning is normal and is not a
failure: it says you have not told GnuPG you believe the key belongs to the
person it claims to.
Now compare the `Primary key fingerprint` line with the fingerprint in this
page. GnuPG prints it as ten space-separated groups of four hex digits, with a
double space in the middle, while the fingerprint above is the same 40
characters unspaced — so compare the hex digits in order and ignore the
spacing, or strip it and let the shell do it:
```sh
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt 2>&1 |
sed -n 's/^Primary key fingerprint: //p' | tr -d ' '
```
That prints the 40-character form, ready to compare with
`A2061F6AB24DF2C0E92346FD1509B54946D08A95`. Do not skip the comparison —
`gpg --verify` exits 0 for a good signature from *any* key in your keyring,
including one an attacker talked you into importing.
A tampered `SHA256SUMS.txt` looks like this, and exits 1:
```
gpg: Signature made Fri 07 Aug 2026 10:11:12 PM CEST
gpg: using EDDSA key 9D1EA241DAEA89E09381A21BDC27E8A3D53C32D6
gpg: BAD signature from "nxdns release signing (throwaway demonstration key) <demo@example.invalid>" [unknown]
```
> Verified on this host. A throwaway ed25519 primary key was generated into a
> temporary `GNUPGHOME`, an ed25519 **signing subkey** was added to it, and the
> stand-in `SHA256SUMS.txt` was signed with `--local-user <subkey-fingerprint>!`
> — the same construction the release workflow uses — so the transcripts above
> are what a subkey-signed release actually prints, rather than what a key
> signing with its primary would. The verification ran from a second
> `GNUPGHOME` holding only that key's public half, which is why the `[unknown]`
> trust marker and the warning are there rather than being written in by hand.
> The second transcript is the same command after one newline was appended to
> `SHA256SUMS.txt`. The `sed`/`tr` pipeline was run against that same output and
> printed `664313AAF527DDAE1C1E516CA36FF8DA4E6C1C07`, the throwaway primary.
## 4. Check the hashes
```sh
sha256sum -c --ignore-missing SHA256SUMS.txt
```
```
nxdns-<version>-x86_64-linux-musl.tar.gz: OK
IMAGE-DIGEST.txt: OK
```
`--ignore-missing` is what makes this work when you downloaded one tarball out
of the two. Without it, `sha256sum` treats every line it cannot read as a
failure and exits 1:
```
nxdns-<version>-x86_64-linux-musl.tar.gz: OK
sha256sum: nxdns-<version>-aarch64-linux-musl.tar.gz: No such file or directory
nxdns-<version>-aarch64-linux-musl.tar.gz: FAILED open or read
IMAGE-DIGEST.txt: OK
sha256sum: WARNING: 1 listed file could not be read
```
A file that is present but does not match is the case that matters, and it
says `FAILED` with no `open or read`:
```
nxdns-<version>-x86_64-linux-musl.tar.gz: FAILED
IMAGE-DIGEST.txt: OK
sha256sum: WARNING: 1 computed checksum did NOT match
```
Check the signature before the hashes, not after. An attacker who can replace
the tarball can replace `SHA256SUMS.txt` next to it; the signature is the only
thing in the set they cannot forge.
> Verified on this host against the stand-in files: all three transcripts are
> real `sha256sum` output over two random-byte files named like the release
> tarballs plus an `IMAGE-DIGEST.txt` holding one image reference, with one
> tarball deleted for the first two blocks and one byte appended to the other
> for the third. Only the version in the filenames was replaced with
> `<version>`.
## 5. Look inside before extracting
```sh
tar -tvzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
```
Expect exactly one top-level directory and the six files listed above, with
mode `-rwxr-xr-x` on `nxdns` and `-rw-r--r--` on the rest, no symlinks, and no
path that begins with `/` or contains `..`. `zig build verify-dist` asserts all
of that on the extracted archive before a release is ever published, so this is
a second opinion rather than the only check — but it costs nothing and it is
the step that catches a tarball that is not the one you think it is.
Then extract:
```sh
tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
./nxdns-$VERSION-x86_64-linux-musl/nxdns version
```
`version` prints the version and the git commit it was built from, then the
Zig version. The version has to match the tag you downloaded, and the commit
has to match the commit the tag points at.
> Not verified on this host: there is no release tarball to list or extract.
## 6. Verify the container image
`IMAGE-DIGEST.txt` holds one line: the image reference this version pushed,
pinned by the digest of its index, in the form
```
git.mial.net/mokhtar/nxdns:<version>@sha256:<64 hex digits>
```
`SHA256SUMS.txt` covers `IMAGE-DIGEST.txt`, so the signature you already
checked covers that line too. Confirm the tag in the registry still resolves to
that digest:
```sh
cut -d@ -f2 IMAGE-DIGEST.txt
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
--format '{{.Manifest.Digest}}'
```
The two have to be the same string. A registry tag is mutable; the digest is
not, so pull the whole pinned reference rather than the tag when you care:
```sh
docker pull "$(cat IMAGE-DIGEST.txt)"
```
The index should carry exactly two platforms and nothing else:
```sh
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}'
```
`linux/amd64 linux/arm64`. The build passes `--provenance=false --sbom=false`,
so there are no `unknown/unknown` attestation entries in the list; seeing any
means the image did not come from this pipeline.
The binary inside the image is the same file as the one in the matching
tarball, and the release checks that before publishing. To check it yourself:
```sh
docker create --name nxdns-verify git.mial.net/mokhtar/nxdns:$VERSION
docker cp nxdns-verify:/nxdns ./nxdns-from-image
docker rm nxdns-verify
sha256sum ./nxdns-from-image ./nxdns-$VERSION-x86_64-linux-musl/nxdns
```
> Not verified against nxdns: no image is published, so no command here was run
> against `git.mial.net/mokhtar/nxdns`. The two
> `docker buildx imagetools inspect --format` shapes were run on this host
> against `alpine:3.22` on Docker Hub — the digest form printed
> `sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce`,
> which is the digest this project's builder stage pins, and the platform form
> printed `linux/amd64 unknown/unknown linux/arm unknown/unknown ...`. That
> `unknown/unknown` is exactly what the paragraph above says nxdns's own index
> must not contain: Alpine's index carries attestation entries, and nxdns's
> build turns them off. Nothing was checked about how nxdns's index will
> actually look.
>
> The `docker create` / `docker cp` / `sha256sum` comparison at the end was run
> here against an image built from this checkout rather than a pulled one, and
> the two hashes matched: the binary copied out of the image and
> `zig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns` were the same
> file.
## What the signature proves, and what it does not
It proves two things:
- The release was produced by this project's release pipeline, using a key
only that pipeline holds.
- What you have on disk is byte for byte what that pipeline uploaded. A
corrupted download, a modified mirror or a tampered proxy all break the
check.
It does not prove that the binary in the tarball was built from the source in
this repository. The machine that ran the build also held the signing key, so
a compromise of that machine produces an artifact that is signed, verifies
cleanly, and contains whatever the attacker put in it. The signature is a
statement about origin and integrity in transit. It is not a statement about
provenance from source.
Closing that gap needs a reproducibility gate — an independent build, run
somewhere else, that lands on the same bytes — and this project does not have
one. It is a recorded deferral, not an oversight: see `specs/milestone-14.md`
ruling 12. Until it exists, nothing here claims the build is reproducible,
because nobody has measured whether it is.
The signing key is a subkey rather than the primary key, which limits the
damage of the case above: a leaked release subkey is revoked on its own and
the identity, the commit signatures and everyone's existing trust in the key
survive.
## Rebuild it yourself
You can still build the same version from source and compare. That gets you a
binary whose provenance you know, and the comparison is worth making — read
the paragraph after the recipe before you draw a conclusion from it.
```sh
git clone https://git.mial.net/mokhtar/nxdns
cd nxdns
git checkout "v$VERSION"
git verify-tag "v$VERSION"
(cd web && npm ci && npm run build)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz
```
`git verify-tag` is the check that the tag itself is signed by the key from
step 3, and it is the one part of this section that stands on its own: it ties
the source you just checked out to the same identity that signed the release.
`zig build dist` writes `zig-out/dist/`: the two tarballs, a staging directory
per target under `stage/`, the stripped binaries under `bin/<triple>/`, and a
`SHA256SUMS` covering the two tarballs. The published `SHA256SUMS.txt` is that
file with a third line for `IMAGE-DIGEST.txt` appended by the release job, so
the two tarball lines should match and the local file has no third line to
compare.
Now the caveat, and it is the whole reason this section is last. **A hash that
differs does not mean the release was tampered with.** Nothing in this project
measures whether two builds of the same commit on two different machines
produce the same bytes, and there are several ordinary reasons they would not:
a different Zig patch release, a different Node version, a different path to
the build directory, a different npm lockfile resolution. A hash that matches
is real evidence. A hash that does not match tells you only that something
about the two builds differed, and finding out what is on you.
If you want the comparison to mean as much as it can, match the toolchain the
release used. The Zig version is the second line of `nxdns version`, and both
it and the Node version are pinned to exact patch releases at the top of
`.gitea/workflows/gates.yml`, which is the workflow the release runs.
> Partly verified on this host. `zig build dist` and `sha256sum` on its output
> were run to completion, with the version read out of `build.zig.zon`: `dist`
> exited 0 and wrote the two tarballs, `SHA256SUMS` and the staged payloads
> described above. `zig build verify-dist` was run on the result too and exited
> 0. What could not be run is everything that needs a release: the clone, the
> checkout and `git verify-tag` need a tag that does not exist, and there is no
> published `SHA256SUMS.txt` to compare a local build against, so the comparison
> this section is about has never been performed.
## If a check fails
Stop and do not run the binary.
- `sha256sum` says `FAILED` but the signature was good — you have a damaged or
substituted download. Delete it and fetch it again over a different network
before assuming anything worse.
- `gpg` says `BAD signature``SHA256SUMS.txt` and `SHA256SUMS.txt.asc` do not
belong together. Re-download both from the release page; a stale
`SHA256SUMS.txt.asc` left over from a previous version is the boring
explanation.
- `gpg` says `Can't check signature: No public key` — you have not imported the
key, or you imported a different one.
- The fingerprint does not match the one in step 3 — that is the case to take
seriously. Do not extract the tarball, and do not import more keys trying to
make it pass.
## Related
- [Install with systemd](install-with-systemd.md) — where the verified tarball
goes next.
- [Install with Docker](install-with-docker.md) — the published image.
- [Upgrade nxdns](upgrade.md) — the same verification, on the way to a newer
version.
- [Performance targets and what the tests prove](../explanation/performance-and-testing.md)
— the other place this project writes down what its checks do not cover.
+8 -2
View File
@@ -13,11 +13,17 @@ see [measure performance](../how-to/measure-performance.md).
| Blocklist lookup p95 < 1 ms | `bench filter`: `matcher.normalize` + `Snapshot.evaluate` per op |
| Cached response p95 < 5 ms | `bench cache`: `buildKey` + `DnsCache.get` + `packet.setId` per op |
| Memory with ~1M blocked domains < 100 MiB | `bench filter`: VmRSS with the 1M-domain snapshot loaded |
| Stripped static binary < 10 MiB per arch (< 15 MiB with the embedded frontend) | CI size assert on the `cross` artifacts |
| Stripped static binary 10,485,760 bytes per arch without the embedded frontend, ≤ 15,728,640 bytes with it | `zig build verify-dist`, run by the `package` gate and by the release |
The harness is `tools/bench.zig`. It measures the three targets that are
measurable in process; the qps target is end to end and the binary-size target
belongs to CI.
belongs to the packaging step.
The two size budgets are exact byte counts, not rounded mebibytes, because an
assert on a rounded number is an assert on a number nobody wrote down.
`verify-dist` checks the shipped binary against the larger budget and builds a
second time against a generated empty assets directory for the smaller one, so
the asset-free figure is a real measurement rather than an estimate.
## Measured: x86_64 development host