milestone 14: build, package, sign and publish releases
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user