milestone 14: build, package, sign and publish releases
This commit is contained in:
+6
-305
@@ -1,314 +1,15 @@
|
||||
name: CI
|
||||
|
||||
# CI is nothing but the gate set. Every blocking check lives in gates.yml so
|
||||
# that release.yml runs the identical set before it publishes anything
|
||||
# (milestone-14 ruling 7). Nothing may be added here: a check that exists in
|
||||
# CI but not in gates.yml is a check a release skips.
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
ZIG_VERSION: "0.16.0"
|
||||
NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Run test suite (unit + hermetic loopback integration)
|
||||
run: zig build test -Dintegration
|
||||
|
||||
test-aarch64:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
# qemu-user, not qemu-user-static: Zig execs the bare `qemu-aarch64`
|
||||
# name, and the -static package only ships `qemu-aarch64-static`.
|
||||
- name: Install qemu-user
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq -y --no-install-recommends qemu-user
|
||||
|
||||
- name: Run test suite under qemu (plain suite, no -Dintegration)
|
||||
run: zig build test-aarch64 -fqemu
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: web
|
||||
run: npm run format:check
|
||||
|
||||
- name: Lint
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
working-directory: web
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
cross:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Build the web UI
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# ReleaseSafe because the < 15 MiB budget (PLAN §18) is for release
|
||||
# binaries; a Debug build strips to roughly 25 MiB and can never meet it.
|
||||
- name: Build static musl executables
|
||||
run: zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
|
||||
- name: Install file(1) and strip tooling
|
||||
run: |
|
||||
missing=""
|
||||
command -v file >/dev/null 2>&1 || missing="$missing file"
|
||||
command -v objcopy >/dev/null 2>&1 || missing="$missing binutils"
|
||||
command -v aarch64-linux-gnu-objcopy >/dev/null 2>&1 || missing="$missing binutils-aarch64-linux-gnu"
|
||||
if [ -n "$missing" ]; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq -y $missing
|
||||
fi
|
||||
|
||||
# The size budget applies to stripped binaries (PLAN §18) and
|
||||
# `zig build cross` does not strip, so the assert measures a
|
||||
# stripped copy and leaves the built artifact untouched.
|
||||
- name: Assert executables are statically linked and within the size budget
|
||||
run: |
|
||||
set -euo pipefail
|
||||
size_limit=$((15 * 1024 * 1024))
|
||||
for triple in x86_64-linux-musl aarch64-linux-musl; do
|
||||
binary="zig-out/cross/$triple/nxdns"
|
||||
if [ ! -f "$binary" ]; then
|
||||
echo "missing executable: $binary"
|
||||
exit 1
|
||||
fi
|
||||
description=$(file -b "$binary")
|
||||
echo "$triple: $description"
|
||||
case "$description" in
|
||||
*"statically linked"*) ;;
|
||||
*)
|
||||
echo "not statically linked: $binary"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$triple" in
|
||||
x86_64-*) strip_tool=objcopy ;;
|
||||
aarch64-*) strip_tool=aarch64-linux-gnu-objcopy ;;
|
||||
esac
|
||||
"$strip_tool" --strip-all "$binary" "$binary.stripped"
|
||||
size=$(stat -c %s "$binary.stripped")
|
||||
echo "$triple: stripped size $size bytes"
|
||||
if [ "$size" -ge "$size_limit" ]; then
|
||||
echo "stripped executable exceeds the 15 MiB budget: $binary"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# PLAN §18 also budgets the binary without web assets (< 10 MiB). A
|
||||
# separate prefix keeps the with-assets artifacts above intact.
|
||||
- name: Build static musl executables without web assets
|
||||
run: zig build cross -Doptimize=ReleaseSafe --prefix zig-out/nodist
|
||||
|
||||
- name: Assert asset-free executables are within the size budget
|
||||
run: |
|
||||
set -euo pipefail
|
||||
size_limit=$((10 * 1024 * 1024))
|
||||
for triple in x86_64-linux-musl aarch64-linux-musl; do
|
||||
binary="zig-out/nodist/cross/$triple/nxdns"
|
||||
if [ ! -f "$binary" ]; then
|
||||
echo "missing executable: $binary"
|
||||
exit 1
|
||||
fi
|
||||
case "$triple" in
|
||||
x86_64-*) strip_tool=objcopy ;;
|
||||
aarch64-*) strip_tool=aarch64-linux-gnu-objcopy ;;
|
||||
esac
|
||||
"$strip_tool" --strip-all "$binary" "$binary.stripped"
|
||||
size=$(stat -c %s "$binary.stripped")
|
||||
echo "$triple: stripped size without assets $size bytes"
|
||||
if [ "$size" -ge "$size_limit" ]; then
|
||||
echo "stripped asset-free executable exceeds the 10 MiB budget: $binary"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Build the web UI
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build static musl executables
|
||||
run: zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
|
||||
- name: Build the image
|
||||
run: docker build -t nxdns:ci -f deploy/docker/Dockerfile .
|
||||
|
||||
- name: Smoke test the container
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm nxdns:ci version
|
||||
|
||||
mkdir -p etc-nxdns
|
||||
cat > etc-nxdns/config.zon <<'EOF'
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
}
|
||||
EOF
|
||||
|
||||
# No bind mount: the runner talks to the daemon over a mounted
|
||||
# socket, so a -v path would resolve on the docker host (where the
|
||||
# workspace does not exist) and mount an empty directory over
|
||||
# /etc/nxdns. docker cp streams the file through the socket instead.
|
||||
#
|
||||
# Networking: this job itself runs in a container on the runner's
|
||||
# per-job network. A published port binds on the daemon's host, not
|
||||
# here, and docker does not route between the default bridge and
|
||||
# that network — a bridge-IP curl hangs to its connect timeout. So
|
||||
# the smoke container joins the job's own network, where its name
|
||||
# resolves and its port is reachable. On a host runner the inspect
|
||||
# finds no container and the published-port path covers it.
|
||||
net=$(docker inspect "$(hostname)" \
|
||||
-f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \
|
||||
2>/dev/null || true)
|
||||
cid=$(docker create --name nxdns-smoke \
|
||||
${net:+--network "$net"} \
|
||||
-p 127.0.0.1:18080:8080 \
|
||||
nxdns:ci)
|
||||
trap 'docker rm -f nxdns-smoke >/dev/null 2>&1 || true' EXIT
|
||||
docker cp etc-nxdns/config.zon nxdns-smoke:/etc/nxdns/config.zon
|
||||
docker start nxdns-smoke
|
||||
|
||||
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid")
|
||||
healthy=""
|
||||
for _ in $(seq 1 30); do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
|
||||
echo "container exited during startup"
|
||||
docker logs "$cid" || true
|
||||
exit 1
|
||||
fi
|
||||
if curl -fsS --connect-timeout 2 "http://nxdns-smoke:8080/api/health" \
|
||||
|| curl -fsS --connect-timeout 2 "http://127.0.0.1:18080/api/health" \
|
||||
|| { [ -n "$ip" ] && curl -fsS --connect-timeout 2 "http://$ip:8080/api/health"; }; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$healthy" ]; then
|
||||
echo "no /api/health response within 30 seconds"
|
||||
docker logs "$cid" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker stop -t 30 nxdns-smoke
|
||||
exit_code=$(docker inspect -f '{{.State.ExitCode}}' nxdns-smoke)
|
||||
echo "exit code after SIGTERM: $exit_code"
|
||||
docker logs nxdns-smoke || true
|
||||
test "$exit_code" -eq 0
|
||||
gates:
|
||||
uses: ./.gitea/workflows/gates.yml
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
name: Gates
|
||||
|
||||
# Every blocking check lives here, and nowhere else. ci.yml calls it on push
|
||||
# and pull_request; release.yml calls it before it builds anything publishable
|
||||
# (milestone-14 ruling 7). Keeping the packaging and container checks in this
|
||||
# file — not only the three test jobs — is the point: a release must not be
|
||||
# able to skip the checks that guard the artifacts it publishes.
|
||||
#
|
||||
# Every action here is pinned to a full commit SHA, on the same grounds as
|
||||
# release.yml (ruling 7). The ruling's carve-out — "ci.yml may keep moving
|
||||
# tags; it holds no secrets" — does not reach this file: release.yml *calls*
|
||||
# it, so these jobs run on the same self-hosted runner host and against the
|
||||
# same docker daemon as the job holding the signing subkey and the registry
|
||||
# token. A moved tag upstream would execute there. ci.yml itself still uses no
|
||||
# third-party action; its only `uses:` is this workflow.
|
||||
#
|
||||
# Resolved with `git ls-remote <repo> refs/tags/vN 'refs/tags/vN^{}'` on
|
||||
# 2026-08-07. All three are lightweight tags — no `^{}` peel row — so the
|
||||
# listed SHA is the commit.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
ZIG_VERSION: "0.16.0"
|
||||
# Exact patch, not a floating "24" (milestone-14 ruling 12): the bundled npm
|
||||
# and the emitted bundle change under a floating major.
|
||||
NODE_VERSION: "24.19.0"
|
||||
# There is deliberately no CI_VERSION literal here. Ruling 2 allows the
|
||||
# version to exist in the tag and in build.zig.zon and nowhere else, and
|
||||
# ruling 5 makes verify-dist fail when the version under build disagrees with
|
||||
# build.zig.zon. An invented CI string such as "0.0.0-ci" therefore cannot
|
||||
# pass this file's own packaging gate. The package and container jobs read
|
||||
# the version out of build.zig.zon instead.
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Run test suite (unit + hermetic loopback integration)
|
||||
run: zig build test -Dintegration
|
||||
|
||||
test-aarch64:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
# qemu-user, not qemu-user-static: Zig execs the bare `qemu-aarch64`
|
||||
# name, and the -static package only ships `qemu-aarch64-static`.
|
||||
- name: Install qemu-user
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq -y --no-install-recommends qemu-user
|
||||
|
||||
- name: Run test suite under qemu (plain suite, no -Dintegration)
|
||||
run: zig build test-aarch64 -fqemu
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
working-directory: web
|
||||
run: npm run format:check
|
||||
|
||||
- name: Lint
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
working-directory: web
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
# The licence inventory has to cover every package whose bytes ship, and
|
||||
# the lockfile does not answer that question: it lists what could be
|
||||
# reached, not what rollup kept. Four packages of the non-dev closure are
|
||||
# recorded as tree-shaken away, and if application code starts importing
|
||||
# one of them, no lockfile, no version and no dependency set changes —
|
||||
# only the bundle does. So the bundle is what this reads.
|
||||
#
|
||||
# A second build with sourcemaps, because the shipped build has none: the
|
||||
# `sources` list of each chunk names the packages whose modules went into
|
||||
# it. The output goes to its own directory so the artifact npm run build
|
||||
# produced is the one that gets embedded, untouched.
|
||||
- name: Assert the packages bundled into web/dist are the recorded ones
|
||||
working-directory: web
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The binary npm ci installed, never `npx`: npx silently downloads a
|
||||
# package it cannot find locally, so a wrong working directory would
|
||||
# turn a licence check into an unpinned fetch from the network.
|
||||
./node_modules/.bin/vite build --sourcemap --outDir dist-sourcemap --emptyOutDir >/dev/null
|
||||
|
||||
maps=$(find dist-sourcemap -name '*.map' -type f | LC_ALL=C sort)
|
||||
if [ -z "$maps" ]; then
|
||||
echo "the sourcemap build produced no .map files; this check cannot run blind"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
bundled=$(jq -r '.sources[]' $maps \
|
||||
| grep 'node_modules/' \
|
||||
| sed 's|.*node_modules/||' \
|
||||
| awk -F/ '{ if ($1 ~ /^@/) print $1"/"$2; else print $1 }' \
|
||||
| LC_ALL=C sort -u)
|
||||
|
||||
recorded=$(awk '
|
||||
/^\[npm packages bundled into web\/dist\]$/ { grab = 1; next }
|
||||
grab && /^\[/ { exit }
|
||||
grab && NF { print }
|
||||
' ../licenses/dependency-identity.txt | LC_ALL=C sort -u)
|
||||
|
||||
if [ -z "$recorded" ]; then
|
||||
echo "licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! diff -u <(printf '%s\n' "$recorded") <(printf '%s\n' "$bundled"); then
|
||||
echo
|
||||
echo "the set of npm packages in web/dist has changed (-recorded +current)."
|
||||
echo "Work out what the change means for licenses/inventory.zon first, then record"
|
||||
echo "the new list in that section of licenses/dependency-identity.txt."
|
||||
exit 1
|
||||
fi
|
||||
echo "web/dist bundles exactly the recorded packages:"
|
||||
printf '%s\n' "$bundled"
|
||||
|
||||
package:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
# `dist` refuses web/dist-placeholder (ruling 4), so the real bundle has
|
||||
# to exist before the packaging gate runs.
|
||||
- name: Build the web UI
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# Ruling 2: build.zig.zon is the only place besides the tag that carries
|
||||
# the version, and ruling 5 makes verify-dist assert the two agree. The
|
||||
# gate builds the version the repository declares.
|
||||
- name: Read the version from build.zig.zon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
|
||||
test -n "$version"
|
||||
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build the release artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zig build dist \
|
||||
-Dversion-string="$CI_VERSION" \
|
||||
-Dgit-commit="$GITHUB_SHA" \
|
||||
-Dweb-dist=web/dist \
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
# verify-dist owns every assert the CI shell used to make: ELF static
|
||||
# linkage, the 15,728,640-byte stripped budget, the archive layout and
|
||||
# modes, `nxdns version`, the build.zig.zon version, and the separate
|
||||
# 10,485,760-byte asset-free build (ruling 5). That is why the objcopy
|
||||
# and binutils-aarch64-linux-gnu install and the two inline size-assert
|
||||
# shell blocks are gone from this file: the checks live in the build
|
||||
# graph now and run identically on a laptop.
|
||||
- name: Verify the release artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zig build verify-dist \
|
||||
-Dversion-string="$CI_VERSION" \
|
||||
-Dgit-commit="$GITHUB_SHA" \
|
||||
-Dweb-dist=web/dist \
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
container:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- name: Set up Zig
|
||||
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Build the web UI
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# Same single source of truth as the package job (rulings 2 and 5).
|
||||
- name: Read the version from build.zig.zon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon | head -1)
|
||||
test -n "$version"
|
||||
echo "CI_VERSION=$version" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build the release artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zig build dist \
|
||||
-Dversion-string="$CI_VERSION" \
|
||||
-Dgit-commit="$GITHUB_SHA" \
|
||||
-Dweb-dist=web/dist \
|
||||
-Doptimize=ReleaseSafe
|
||||
|
||||
# Image tags, container names and published host ports are all
|
||||
# daemon-global. This workflow is called by both ci.yml and release.yml
|
||||
# and the self-hosted runners share one docker daemon, so the fixed
|
||||
# `nxdns:ci` and `nxdns-smoke` made two concurrent runs collide: the
|
||||
# second `docker create --name` fails outright, and worse, whichever run
|
||||
# finishes first deletes the other's container mid-test. The names below
|
||||
# carry the run identity plus four random bytes — the run id alone is not
|
||||
# enough, because two *repositories* on the same daemon can issue the
|
||||
# same one. The published port becomes ephemeral for the same reason.
|
||||
- name: Name this run's docker objects
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rand=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
suffix="${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-$rand"
|
||||
{
|
||||
echo "CI_IMAGE=nxdns:ci-$suffix"
|
||||
echo "SMOKE_NAME=nxdns-smoke-$suffix"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "image nxdns:ci-$suffix, container nxdns-smoke-$suffix"
|
||||
|
||||
# The build args carry the OCI label values (ruling 6); release.yml
|
||||
# passes the same three and then asserts the resulting
|
||||
# org.opencontainers.image.version label. BuildKit is not optional here:
|
||||
# the builder stage is pinned to $BUILDPLATFORM, which the classic
|
||||
# builder does not define, so DOCKER_BUILDKIT=0 fails at the first FROM.
|
||||
- name: Build the image
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t "$CI_IMAGE" -f deploy/docker/Dockerfile \
|
||||
--build-arg VERSION="$CI_VERSION" \
|
||||
--build-arg REVISION="$GITHUB_SHA" \
|
||||
--build-arg CREATED="1970-01-01T00:00:00Z" \
|
||||
.
|
||||
|
||||
# Ruling 6: the binary in the image must be the binary in the tarball.
|
||||
# Ruling 3: distributing the image is distribution, so /LICENSE and
|
||||
# /THIRD-PARTY-NOTICES must be in it and must be the same files the
|
||||
# tarball carries — that is an acceptance criterion and nothing checked
|
||||
# it. Comparing against the staged payload rather than merely asserting
|
||||
# the paths exist costs nothing and catches a stale or empty copy.
|
||||
#
|
||||
# Native triple only: this job builds a single-architecture image.
|
||||
# release.yml covers both platforms against the pushed multi-arch index.
|
||||
- name: Assert the image contents match the packaged artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stage="zig-out/dist/stage/nxdns-$CI_VERSION-x86_64-linux-musl"
|
||||
test -d "$stage"
|
||||
|
||||
out=$(mktemp -d)
|
||||
cid=$(docker create "$CI_IMAGE")
|
||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true; rm -rf "$out"' EXIT
|
||||
|
||||
rc=0
|
||||
for member in nxdns LICENSE THIRD-PARTY-NOTICES; do
|
||||
docker cp "$cid:/$member" "$out/$member"
|
||||
want=$(sha256sum "$stage/$member" | cut -d' ' -f1)
|
||||
got=$(sha256sum "$out/$member" | cut -d' ' -f1)
|
||||
if [ "$want" = "$got" ]; then
|
||||
echo "/$member matches ($got)"
|
||||
else
|
||||
echo "/$member DIFFERS: image $got, packaged $want"
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
test "$rc" -eq 0
|
||||
|
||||
- name: Smoke test the container
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm "$CI_IMAGE" version
|
||||
|
||||
mkdir -p etc-nxdns
|
||||
cat > etc-nxdns/config.zon <<'EOF'
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
}
|
||||
EOF
|
||||
|
||||
# No bind mount: the runner talks to the daemon over a mounted
|
||||
# socket, so a -v path would resolve on the docker host (where the
|
||||
# workspace does not exist) and mount an empty directory over
|
||||
# /etc/nxdns. docker cp streams the file through the socket instead.
|
||||
#
|
||||
# Networking: this job itself runs in a container on the runner's
|
||||
# per-job network. A published port binds on the daemon's host, not
|
||||
# here, and docker does not route between the default bridge and
|
||||
# that network — a bridge-IP curl hangs to its connect timeout. So
|
||||
# the smoke container joins the job's own network, where its name
|
||||
# resolves and its port is reachable. On a host runner the inspect
|
||||
# finds no container and the published-port path covers it.
|
||||
#
|
||||
# `-p 127.0.0.1::8080` takes an ephemeral host port instead of a
|
||||
# fixed 18080, which two concurrent runs on this daemon cannot both
|
||||
# bind. The actual port is read back with `docker port`.
|
||||
net=$(docker inspect "$(hostname)" \
|
||||
-f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' \
|
||||
2>/dev/null || true)
|
||||
cid=$(docker create --name "$SMOKE_NAME" \
|
||||
${net:+--network "$net"} \
|
||||
-p 127.0.0.1::8080 \
|
||||
"$CI_IMAGE")
|
||||
trap 'docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true' EXIT
|
||||
docker cp etc-nxdns/config.zon "$SMOKE_NAME:/etc/nxdns/config.zon"
|
||||
docker start "$SMOKE_NAME"
|
||||
|
||||
hostport=$(docker port "$SMOKE_NAME" 8080/tcp 2>/dev/null | head -1 | awk -F: '{ print $NF }')
|
||||
ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$cid")
|
||||
echo "published host port: ${hostport:-none}, container ip: ${ip:-none}"
|
||||
healthy=""
|
||||
for _ in $(seq 1 30); do
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$cid")" != "true" ]; then
|
||||
echo "container exited during startup"
|
||||
docker logs "$cid" || true
|
||||
exit 1
|
||||
fi
|
||||
if curl -fsS --connect-timeout 2 "http://$SMOKE_NAME:8080/api/health" \
|
||||
|| { [ -n "$hostport" ] && curl -fsS --connect-timeout 2 "http://127.0.0.1:$hostport/api/health"; } \
|
||||
|| { [ -n "$ip" ] && curl -fsS --connect-timeout 2 "http://$ip:8080/api/health"; }; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$healthy" ]; then
|
||||
echo "no /api/health response within 30 seconds"
|
||||
docker logs "$cid" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker stop -t 30 "$SMOKE_NAME"
|
||||
exit_code=$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE_NAME")
|
||||
echo "exit code after SIGTERM: $exit_code"
|
||||
docker logs "$SMOKE_NAME" || true
|
||||
test "$exit_code" -eq 0
|
||||
|
||||
# The per-step traps only cover the step that set them. This is the exit
|
||||
# path for a cancelled job, a step that died before its trap was
|
||||
# installed, and the image itself, which no trap ever removed — on a
|
||||
# long-lived self-hosted daemon those accumulate one layer set per run.
|
||||
- name: Remove this run's docker objects
|
||||
if: always()
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -n "${SMOKE_NAME:-}" ]; then
|
||||
docker rm -f "$SMOKE_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "${CI_IMAGE:-}" ]; then
|
||||
docker image rm -f "$CI_IMAGE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
@@ -23,5 +23,14 @@ jobs:
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
# zig 0.16.0's package fetcher creates tmp/<hex>.zip inside the global
|
||||
# cache without creating tmp/ first (src/Package/Fetch.zig:1499), and
|
||||
# setup-zig's restored cache never contains tmp/. Without this, every
|
||||
# dependency fetch dies with "failed to create temporary zip file:
|
||||
# FileNotFound" before any network I/O — which this workflow, alone among
|
||||
# the four, was missing.
|
||||
- name: Create the fetch temp dir zig assumes
|
||||
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
|
||||
|
||||
- name: Run full test suite including live-network tests
|
||||
run: zig build test -Dintegration -Dlive
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,4 @@ zig-out/
|
||||
zig-pkg/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/dist-sourcemap/
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to nxdns are recorded here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
Sections are written by hand. Nothing here is generated from commit messages:
|
||||
the point of the file is to say what changed for an operator, which a commit
|
||||
subject rarely does.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.1] - 2026-08-07
|
||||
|
||||
First release. Everything below is new.
|
||||
|
||||
### Added
|
||||
|
||||
- **Forwarding DNS server.** UDP and TCP listeners with a wire-format parser and
|
||||
encoder written against RFC 1035 and EDNS(0), a bounded worker model, per-client
|
||||
rate limiting and a `pause` control that stops filtering without stopping
|
||||
resolution.
|
||||
- **Encrypted upstreams.** DNS-over-HTTPS and DNS-over-TLS clients over a pool
|
||||
that tracks per-upstream health and fails over, with SNI and certificate
|
||||
verification driven by a per-upstream TLS name.
|
||||
- **DoH and DoT endpoints.** nxdns also answers as an encrypted resolver, with a
|
||||
certificate store that reloads on disk changes and through the API, so renewals
|
||||
do not need a restart.
|
||||
- **Blocklist filtering.** Subscriptions in hosts, plain-domain and
|
||||
Adblock-Plus-style formats, compiled into a compact matcher; per-group allow
|
||||
and block rules with wildcards; safe-search enforcement.
|
||||
- **Per-client policy groups.** Clients are identified by address and assigned to
|
||||
groups, so the filtering a device gets depends on which device it is.
|
||||
- **Local DNS.** Local A/AAAA/CNAME/PTR records and conditional forwarding of
|
||||
internal zones to another resolver.
|
||||
- **Cache.** A bounded in-memory cache that respects upstream TTLs and expires
|
||||
entries rather than serving them stale.
|
||||
- **Query log.** Queries land in SQLite under a retention policy in both rows and
|
||||
days, with disk-full self-protection that degrades instead of corrupting, and a
|
||||
live SSE stream of the same events.
|
||||
- **Web UI and REST API.** A React single-page admin UI embedded in the binary,
|
||||
a REST API with a served OpenAPI document, session authentication, API rate
|
||||
limiting and Prometheus-style `/metrics`.
|
||||
- **Configuration.** A ZON configuration file seeds the database on first boot;
|
||||
after that the database is the truth, and `nxdns export` / `nxdns import` move
|
||||
configuration in and out. `nxdns check` validates a file without starting.
|
||||
- **CLI.** `run`, `check`, `export`, `import`, `version` and `help`.
|
||||
- **Packaging.** A hardened systemd unit with a sysusers fragment, and a
|
||||
`FROM scratch` container image holding the binary, a CA bundle and the licence
|
||||
files, assembled by a builder stage pinned to `alpine:3.22` by digest. Nothing
|
||||
from Alpine ships in the published image except that CA bundle.
|
||||
- **Releases.** Tags publish five assets — static musl tarballs for
|
||||
`x86_64-linux-musl` and `aarch64-linux-musl`, `IMAGE-DIGEST.txt` naming the
|
||||
multi-architecture container image by digest, `SHA256SUMS.txt` over those
|
||||
three, and `SHA256SUMS.txt.asc`, a detached signature over the checksum file.
|
||||
`zig build dist` and `zig build verify-dist` produce and check the same
|
||||
artifacts on a laptop.
|
||||
- **Licensing.** EUPL-1.2, with a `THIRD-PARTY-NOTICES` file in every tarball and
|
||||
image assembled from a reviewed inventory of what the artifacts contain.
|
||||
- **Documentation.** A Diátaxis split — tutorial, how-to, reference, explanation —
|
||||
with drift guards that fail the build when the reference pages fall behind the
|
||||
code.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# Install nxdns
|
||||
|
||||
This directory is an nxdns release for one architecture. It holds:
|
||||
|
||||
| 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` | This file |
|
||||
|
||||
The binary is statically linked against musl and needs nothing installed on the
|
||||
target host.
|
||||
|
||||
Verify the download before you trust it. `docs/how-to/verify-a-release.md` in
|
||||
the repository covers where the public key comes from, what fingerprint to
|
||||
expect, and what the signature does and does not prove.
|
||||
|
||||
## 1. Install the binary, the user and the unit
|
||||
|
||||
Run as root on the target host:
|
||||
|
||||
```sh
|
||||
install -m 0755 nxdns /usr/local/bin/nxdns
|
||||
|
||||
install -m 0644 nxdns.conf /usr/lib/sysusers.d/nxdns.conf
|
||||
systemd-sysusers
|
||||
|
||||
install -m 0644 nxdns.service /etc/systemd/system/nxdns.service
|
||||
systemctl daemon-reload
|
||||
|
||||
mkdir -p -m 0755 /etc/nxdns
|
||||
```
|
||||
|
||||
`nxdns.conf` ships under the name it is installed as, so there is no rename to
|
||||
get wrong.
|
||||
|
||||
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's
|
||||
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
|
||||
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
|
||||
|
||||
## 2. Write the seed configuration
|
||||
|
||||
nxdns starts from an empty database only if a configuration file tells it what
|
||||
to forward to. Write `/etc/nxdns/config.zon`:
|
||||
|
||||
```zon
|
||||
.{
|
||||
.groups = .{ .{ .name = "default" } },
|
||||
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
|
||||
.web = .{ .password = "choose-a-real-password" },
|
||||
}
|
||||
```
|
||||
|
||||
That file holds a password in plain text. Root's umask is 022 on most
|
||||
distributions, so restrict it as soon as you have written it:
|
||||
|
||||
```sh
|
||||
chown root:nxdns /etc/nxdns/config.zon
|
||||
chmod 0640 /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
0640 with group `nxdns` rather than 0600: the service runs as `nxdns` and has to
|
||||
read this file on the first start, and systemd leaves `/etc/nxdns` owned by
|
||||
root.
|
||||
|
||||
Check it before starting the service:
|
||||
|
||||
```sh
|
||||
nxdns check --config /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
A good file ends with `OK: no problems found`. Exit 2 means `check` found
|
||||
something to fix and printed every problem it found. The upstream probe sends a
|
||||
real query, so this needs working DNS on the host.
|
||||
|
||||
The file seeds the database once. From the second start onwards it is ignored
|
||||
and the database is the configuration. The seed's `web.password` is hashed at
|
||||
import time and the plaintext is never stored, so once you have logged in you
|
||||
can delete the file:
|
||||
|
||||
```sh
|
||||
rm /etc/nxdns/config.zon
|
||||
```
|
||||
|
||||
A kept seed is not a backup. `nxdns export` is.
|
||||
|
||||
## 3. Start it
|
||||
|
||||
```sh
|
||||
systemctl enable --now nxdns
|
||||
journalctl -u nxdns -f
|
||||
```
|
||||
|
||||
A healthy start logs a line naming every socket it bound. Port 53 is
|
||||
privileged, and the unit grants `CAP_NET_BIND_SERVICE` through
|
||||
`AmbientCapabilities`.
|
||||
|
||||
## 4. Confirm it answers
|
||||
|
||||
From another machine on the LAN:
|
||||
|
||||
```sh
|
||||
dig @<server-ip> example.com A +short
|
||||
```
|
||||
|
||||
The admin interface is on port 8080 by default; log in with the password from
|
||||
the seed file. `http://<server-ip>:8080/api/health` reports upstream
|
||||
availability and disk state without a login.
|
||||
|
||||
## More
|
||||
|
||||
The full documentation lives in the repository at
|
||||
<https://git.mial.net/mokhtar/nxdns>:
|
||||
|
||||
- `docs/how-to/install-with-systemd.md` — this procedure with the reasoning
|
||||
- `docs/how-to/verify-a-release.md` — verifying the download
|
||||
- `docs/how-to/troubleshoot.md` — first-install failures
|
||||
- `docs/reference/configuration.md` — every configuration field
|
||||
- `docs/reference/cli.md` — every command and flag
|
||||
@@ -0,0 +1,300 @@
|
||||
Copyright (c) 2026 Mokhtar Mial
|
||||
|
||||
Licensed under the EUPL
|
||||
|
||||
SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
The source code of this Work is available at https://git.mial.net/mokhtar/nxdns
|
||||
|
||||
The full text of the European Union Public Licence v. 1.2 (English version,
|
||||
as published by the European Commission) follows.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
EUROPEAN UNION PUBLIC LICENCE v. 1.2
|
||||
EUPL © the European Union 2007, 2016
|
||||
|
||||
This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
|
||||
below) which is provided under the terms of this Licence. Any use of the Work,
|
||||
other than as authorised under this Licence is prohibited (to the extent such
|
||||
use is covered by a right of the copyright holder of the Work).
|
||||
|
||||
The Work is provided under the terms of this Licence when the Licensor (as
|
||||
defined below) has placed the following notice immediately following the
|
||||
copyright notice for the Work:
|
||||
|
||||
Licensed under the EUPL
|
||||
|
||||
or has expressed by any other means his willingness to license under the EUPL.
|
||||
|
||||
1. Definitions
|
||||
|
||||
In this Licence, the following terms have the following meaning:
|
||||
|
||||
- ‘The Licence’: this Licence.
|
||||
|
||||
- ‘The Original Work’: the work or software distributed or communicated by the
|
||||
Licensor under this Licence, available as Source Code and also as Executable
|
||||
Code as the case may be.
|
||||
|
||||
- ‘Derivative Works’: the works or software that could be created by the
|
||||
Licensee, based upon the Original Work or modifications thereof. This Licence
|
||||
does not define the extent of modification or dependence on the Original Work
|
||||
required in order to classify a work as a Derivative Work; this extent is
|
||||
determined by copyright law applicable in the country mentioned in Article 15.
|
||||
|
||||
- ‘The Work’: the Original Work or its Derivative Works.
|
||||
|
||||
- ‘The Source Code’: the human-readable form of the Work which is the most
|
||||
convenient for people to study and modify.
|
||||
|
||||
- ‘The Executable Code’: any code which has generally been compiled and which is
|
||||
meant to be interpreted by a computer as a program.
|
||||
|
||||
- ‘The Licensor’: the natural or legal person that distributes or communicates
|
||||
the Work under the Licence.
|
||||
|
||||
- ‘Contributor(s)’: any natural or legal person who modifies the Work under the
|
||||
Licence, or otherwise contributes to the creation of a Derivative Work.
|
||||
|
||||
- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
|
||||
the Work under the terms of the Licence.
|
||||
|
||||
- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
|
||||
renting, distributing, communicating, transmitting, or otherwise making
|
||||
available, online or offline, copies of the Work or providing access to its
|
||||
essential functionalities at the disposal of any other natural or legal
|
||||
person.
|
||||
|
||||
2. Scope of the rights granted by the Licence
|
||||
|
||||
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
|
||||
sublicensable licence to do the following, for the duration of copyright vested
|
||||
in the Original Work:
|
||||
|
||||
- use the Work in any circumstance and for all usage,
|
||||
- reproduce the Work,
|
||||
- modify the Work, and make Derivative Works based upon the Work,
|
||||
- communicate to the public, including the right to make available or display
|
||||
the Work or copies thereof to the public and perform publicly, as the case may
|
||||
be, the Work,
|
||||
- distribute the Work or copies thereof,
|
||||
- lend and rent the Work or copies thereof,
|
||||
- sublicense rights in the Work or copies thereof.
|
||||
|
||||
Those rights can be exercised on any media, supports and formats, whether now
|
||||
known or later invented, as far as the applicable law permits so.
|
||||
|
||||
In the countries where moral rights apply, the Licensor waives his right to
|
||||
exercise his moral right to the extent allowed by law in order to make effective
|
||||
the licence of the economic rights here above listed.
|
||||
|
||||
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
|
||||
any patents held by the Licensor, to the extent necessary to make use of the
|
||||
rights granted on the Work under this Licence.
|
||||
|
||||
3. Communication of the Source Code
|
||||
|
||||
The Licensor may provide the Work either in its Source Code form, or as
|
||||
Executable Code. If the Work is provided as Executable Code, the Licensor
|
||||
provides in addition a machine-readable copy of the Source Code of the Work
|
||||
along with each copy of the Work that the Licensor distributes or indicates, in
|
||||
a notice following the copyright notice attached to the Work, a repository where
|
||||
the Source Code is easily and freely accessible for as long as the Licensor
|
||||
continues to distribute or communicate the Work.
|
||||
|
||||
4. Limitations on copyright
|
||||
|
||||
Nothing in this Licence is intended to deprive the Licensee of the benefits from
|
||||
any exception or limitation to the exclusive rights of the rights owners in the
|
||||
Work, of the exhaustion of those rights or of other applicable limitations
|
||||
thereto.
|
||||
|
||||
5. Obligations of the Licensee
|
||||
|
||||
The grant of the rights mentioned above is subject to some restrictions and
|
||||
obligations imposed on the Licensee. Those obligations are the following:
|
||||
|
||||
Attribution right: The Licensee shall keep intact all copyright, patent or
|
||||
trademarks notices and all notices that refer to the Licence and to the
|
||||
disclaimer of warranties. The Licensee must include a copy of such notices and a
|
||||
copy of the Licence with every copy of the Work he/she distributes or
|
||||
communicates. The Licensee must cause any Derivative Work to carry prominent
|
||||
notices stating that the Work has been modified and the date of modification.
|
||||
|
||||
Copyleft clause: If the Licensee distributes or communicates copies of the
|
||||
Original Works or Derivative Works, this Distribution or Communication will be
|
||||
done under the terms of this Licence or of a later version of this Licence
|
||||
unless the Original Work is expressly distributed only under this version of the
|
||||
Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
|
||||
(becoming Licensor) cannot offer or impose any additional terms or conditions on
|
||||
the Work or Derivative Work that alter or restrict the terms of the Licence.
|
||||
|
||||
Compatibility clause: If the Licensee Distributes or Communicates Derivative
|
||||
Works or copies thereof based upon both the Work and another work licensed under
|
||||
a Compatible Licence, this Distribution or Communication can be done under the
|
||||
terms of this Compatible Licence. For the sake of this clause, ‘Compatible
|
||||
Licence’ refers to the licences listed in the appendix attached to this Licence.
|
||||
Should the Licensee's obligations under the Compatible Licence conflict with
|
||||
his/her obligations under this Licence, the obligations of the Compatible
|
||||
Licence shall prevail.
|
||||
|
||||
Provision of Source Code: When distributing or communicating copies of the Work,
|
||||
the Licensee will provide a machine-readable copy of the Source Code or indicate
|
||||
a repository where this Source will be easily and freely available for as long
|
||||
as the Licensee continues to distribute or communicate the Work.
|
||||
|
||||
Legal Protection: This Licence does not grant permission to use the trade names,
|
||||
trademarks, service marks, or names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the copyright notice.
|
||||
|
||||
6. Chain of Authorship
|
||||
|
||||
The original Licensor warrants that the copyright in the Original Work granted
|
||||
hereunder is owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each Contributor warrants that the copyright in the modifications he/she brings
|
||||
to the Work are owned by him/her or licensed to him/her and that he/she has the
|
||||
power and authority to grant the Licence.
|
||||
|
||||
Each time You accept the Licence, the original Licensor and subsequent
|
||||
Contributors grant You a licence to their contributions to the Work, under the
|
||||
terms of this Licence.
|
||||
|
||||
7. Disclaimer of Warranty
|
||||
|
||||
The Work is a work in progress, which is continuously improved by numerous
|
||||
Contributors. It is not a finished work and may therefore contain defects or
|
||||
‘bugs’ inherent to this type of development.
|
||||
|
||||
For the above reason, the Work is provided under the Licence on an ‘as is’ basis
|
||||
and without warranties of any kind concerning the Work, including without
|
||||
limitation merchantability, fitness for a particular purpose, absence of defects
|
||||
or errors, accuracy, non-infringement of intellectual property rights other than
|
||||
copyright as stated in Article 6 of this Licence.
|
||||
|
||||
This disclaimer of warranty is an essential part of the Licence and a condition
|
||||
for the grant of any rights to the Work.
|
||||
|
||||
8. Disclaimer of Liability
|
||||
|
||||
Except in the cases of wilful misconduct or damages directly caused to natural
|
||||
persons, the Licensor will in no event be liable for any direct or indirect,
|
||||
material or moral, damages of any kind, arising out of the Licence or of the use
|
||||
of the Work, including without limitation, damages for loss of goodwill, work
|
||||
stoppage, computer failure or malfunction, loss of data or any commercial
|
||||
damage, even if the Licensor has been advised of the possibility of such damage.
|
||||
However, the Licensor will be liable under statutory product liability laws as
|
||||
far such laws apply to the Work.
|
||||
|
||||
9. Additional agreements
|
||||
|
||||
While distributing the Work, You may choose to conclude an additional agreement,
|
||||
defining obligations or services consistent with this Licence. However, if
|
||||
accepting obligations, You may act only on your own behalf and on your sole
|
||||
responsibility, not on behalf of the original Licensor or any other Contributor,
|
||||
and only if You agree to indemnify, defend, and hold each Contributor harmless
|
||||
for any liability incurred by, or claims asserted against such Contributor by
|
||||
the fact You have accepted any warranty or additional liability.
|
||||
|
||||
10. Acceptance of the Licence
|
||||
|
||||
The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
|
||||
placed under the bottom of a window displaying the text of this Licence or by
|
||||
affirming consent in any other similar way, in accordance with the rules of
|
||||
applicable law. Clicking on that icon indicates your clear and irrevocable
|
||||
acceptance of this Licence and all of its terms and conditions.
|
||||
|
||||
Similarly, you irrevocably accept this Licence and all of its terms and
|
||||
conditions by exercising any rights granted to You by Article 2 of this Licence,
|
||||
such as the use of the Work, the creation by You of a Derivative Work or the
|
||||
Distribution or Communication by You of the Work or copies thereof.
|
||||
|
||||
11. Information to the public
|
||||
|
||||
In case of any Distribution or Communication of the Work by means of electronic
|
||||
communication by You (for example, by offering to download the Work from a
|
||||
remote location) the distribution channel or media (for example, a website) must
|
||||
at least provide to the public the information requested by the applicable law
|
||||
regarding the Licensor, the Licence and the way it may be accessible, concluded,
|
||||
stored and reproduced by the Licensee.
|
||||
|
||||
12. Termination of the Licence
|
||||
|
||||
The Licence and the rights granted hereunder will terminate automatically upon
|
||||
any breach by the Licensee of the terms of the Licence.
|
||||
|
||||
Such a termination will not terminate the licences of any person who has
|
||||
received the Work from the Licensee under the Licence, provided such persons
|
||||
remain in full compliance with the Licence.
|
||||
|
||||
13. Miscellaneous
|
||||
|
||||
Without prejudice of Article 9 above, the Licence represents the complete
|
||||
agreement between the Parties as to the Work.
|
||||
|
||||
If any provision of the Licence is invalid or unenforceable under applicable
|
||||
law, this will not affect the validity or enforceability of the Licence as a
|
||||
whole. Such provision will be construed or reformed so as necessary to make it
|
||||
valid and enforceable.
|
||||
|
||||
The European Commission may publish other linguistic versions or new versions of
|
||||
this Licence or updated versions of the Appendix, so far this is required and
|
||||
reasonable, without reducing the scope of the rights granted by the Licence. New
|
||||
versions of the Licence will be published with a unique version number.
|
||||
|
||||
All linguistic versions of this Licence, approved by the European Commission,
|
||||
have identical value. Parties can take advantage of the linguistic version of
|
||||
their choice.
|
||||
|
||||
14. Jurisdiction
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- any litigation resulting from the interpretation of this License, arising
|
||||
between the European Union institutions, bodies, offices or agencies, as a
|
||||
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
|
||||
of Justice of the European Union, as laid down in article 272 of the Treaty on
|
||||
the Functioning of the European Union,
|
||||
|
||||
- any litigation arising between other parties and resulting from the
|
||||
interpretation of this License, will be subject to the exclusive jurisdiction
|
||||
of the competent court where the Licensor resides or conducts its primary
|
||||
business.
|
||||
|
||||
15. Applicable Law
|
||||
|
||||
Without prejudice to specific agreement between parties,
|
||||
|
||||
- this Licence shall be governed by the law of the European Union Member State
|
||||
where the Licensor has his seat, resides or has his registered office,
|
||||
|
||||
- this licence shall be governed by Belgian law if the Licensor has no seat,
|
||||
residence or registered office inside a European Union Member State.
|
||||
|
||||
Appendix
|
||||
|
||||
‘Compatible Licences’ according to Article 5 EUPL are:
|
||||
|
||||
- GNU General Public License (GPL) v. 2, v. 3
|
||||
- GNU Affero General Public License (AGPL) v. 3
|
||||
- Open Software License (OSL) v. 2.1, v. 3.0
|
||||
- Eclipse Public License (EPL) v. 1.0
|
||||
- CeCILL v. 2.0, v. 2.1
|
||||
- Mozilla Public Licence (MPL) v. 2
|
||||
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
|
||||
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
|
||||
works other than software
|
||||
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
|
||||
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
|
||||
Reciprocity (LiLiQ-R+).
|
||||
|
||||
The European Commission may update this Appendix to later versions of the above
|
||||
licences without producing a new version of the EUPL, as long as they provide
|
||||
the rights granted in Article 2 of this Licence and protect the covered Source
|
||||
Code from exclusive appropriation.
|
||||
|
||||
All other changes or additions to this Appendix require the production of a new
|
||||
EUPL version.
|
||||
@@ -38,7 +38,7 @@ Serves a household LAN (≈2–20 devices). Portfolio-grade public repo with ext
|
||||
- DNSSEC validation (DO bit passthrough only).
|
||||
- DoQ (QUIC), HTTP/2 upstream transport.
|
||||
- Clustering / distributed state.
|
||||
- Prebuilt binaries / published Docker images / project website. Repo + documented build-it-yourself path only.
|
||||
- Project website. The repository, its README and `docs/` are the whole published surface.
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +142,8 @@ IPv4 + IPv6 full parity for: client identity, rate limiting, logging, group assi
|
||||
### 3.15 CI
|
||||
|
||||
- Self-hosted **Gitea + Gitea Actions runner**.
|
||||
- Jobs: `zig build test`, fuzz smoke, integration tests, OpenAPI contract tests (live server validated against `openapi.yaml`), frontend build, cross-compile both targets. aarch64 test execution via qemu-user if the runner is x86_64.
|
||||
- Every blocking check lives in one reusable workflow, `.gitea/workflows/gates.yml`. `ci.yml` calls it on `master` pushes and pull requests; `release.yml` calls the same file before it publishes, so a release cannot skip a check that CI runs.
|
||||
- Jobs: `zig build test`, fuzz smoke, integration tests, OpenAPI contract tests (live server validated against `openapi.yaml`), frontend build and tests, `dist` + `verify-dist` for both targets, and a container job that builds the image and asserts its binaries are byte-identical to the packaged ones. aarch64 test execution via qemu-user if the runner is x86_64.
|
||||
|
||||
---
|
||||
|
||||
@@ -568,7 +569,7 @@ Requirements: responsive desktop/mobile; route loaders for initial fetch; TanSta
|
||||
- `nxdns check` — validate config, probe upstreams, load each enabled listener's certificate and verify its key pairs with it; exit 2 on failure, 0 with warnings.
|
||||
- `nxdns export [--out file.zon]`
|
||||
- `nxdns import <file.zon> [--force]`
|
||||
- `nxdns version` — app version, Zig version string, build date, git commit.
|
||||
- `nxdns version` — app version, Zig version string, git commit. No build date: the version and the commit identify a build exactly, and a date is one more input a reproducible build would have to pin.
|
||||
|
||||
---
|
||||
|
||||
@@ -635,7 +636,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
|
||||
- Blocklist lookup p95 < 1 ms.
|
||||
- Cached response p95 < 5 ms.
|
||||
- Memory with ~1M blocked domains < 100 MiB.
|
||||
- Stripped static binary < 10 MiB per arch (excluding embedded frontend assets; < 15 MiB with them).
|
||||
- Stripped static binary per arch: < 15,728,640 bytes with the embedded frontend assets, < 10,485,760 bytes without them. `zig build verify-dist` asserts both.
|
||||
|
||||
---
|
||||
|
||||
@@ -649,7 +650,24 @@ Exit: documented deployment works end-to-end on the Pi 5.
|
||||
|
||||
---
|
||||
|
||||
## 20. Success Criteria
|
||||
## 20. Publication
|
||||
|
||||
The project publishes released binaries and container images from its own Gitea
|
||||
instance. Building from source stays fully supported and documented; it is no
|
||||
longer the only path.
|
||||
|
||||
- **Trigger.** Pushing an annotated, GPG-signed tag `vX.Y.Z` to `git.mial.net/mokhtar/nxdns`. Nothing else publishes. Pre-release tags are rejected.
|
||||
- **Version.** The tag is authoritative. `build.zig.zon`'s `.version` must equal the tag, and the packaging gate asserts it. Nowhere else stores a version.
|
||||
- **Artifacts.** Per architecture (`x86_64-linux-musl`, `aarch64-linux-musl`) a `.tar.gz` holding the stripped ReleaseSafe binary, `LICENSE`, `THIRD-PARTY-NOTICES` and the README. Plus one `SHA256SUMS` covering both tarballs and the image digest, and one detached `SHA256SUMS.asc`.
|
||||
- **Images.** One multi-architecture image at `git.mial.net/mokhtar/nxdns`, tagged with the exact version and `latest`. No `:edge`. `latest` moves only forward. The builder stage runs on `$BUILDPLATFORM`, so no release build needs qemu.
|
||||
- **Signing.** A GPG signing subkey held only by Gitea signs `SHA256SUMS`. The tag itself is signed by the primary key, and the release job pins that primary fingerprint before it does anything else. Verification is documented in `docs/how-to/verify-a-release.md`.
|
||||
- **Gates.** `release.yml` runs `gates.yml` — the same file CI runs — and publishes nothing if any gate fails.
|
||||
- **Licensing.** EUPL-1.2. `THIRD-PARTY-NOTICES` is generated from `licenses/inventory.zon` and a drift test fails when a dependency changes without a matching notice entry.
|
||||
- **Deferred.** Bit-for-bit reproducibility across machines, and SBOM generation, are deliberate deferrals. See `specs/milestone-14.md` ruling 12.
|
||||
|
||||
---
|
||||
|
||||
## 21. Success Criteria
|
||||
|
||||
1. `nxdns run` starts cleanly on Zig 0.16.0 stable, static musl, both arches.
|
||||
2. UDP + TCP resolution works; blocked domains return the configured response; precedence per §3.10.
|
||||
@@ -667,7 +685,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
|
||||
|
||||
---
|
||||
|
||||
## 21. Working Notes
|
||||
## 22. Working Notes
|
||||
|
||||
- `dns/`, `filter/`, `local/`, `cache/` stay pure (no `Io`, no sockets). Servers, upstream clients, and storage take `io: Io`.
|
||||
- Verify stdlib behavior against `../zig` (tag 0.16.0) instead of memory — the std.Io migration invalidated older knowledge once already.
|
||||
|
||||
@@ -20,14 +20,30 @@ you what asked for what.
|
||||
- Prometheus-style `/metrics`, per-client rate limiting, disk-full
|
||||
self-protection
|
||||
|
||||
## Install
|
||||
|
||||
**No release exists yet.** This repository has no tags, nothing has been
|
||||
published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container
|
||||
image has been pushed. Every release URL on this page and in the how-to guides
|
||||
is a 404 today, and `docker pull` finds nothing. Until the first tag ships,
|
||||
building from source is the only way to get nxdns.
|
||||
|
||||
What a tag will publish, once one exists: five assets — two static musl
|
||||
tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`,
|
||||
`nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the
|
||||
multi-architecture container image by digest, `SHA256SUMS.txt` covering those
|
||||
three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the
|
||||
checksum file. Verify what you downloaded before you run it:
|
||||
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also
|
||||
says what that signature does and does not prove.
|
||||
|
||||
## Quickstart (docker compose)
|
||||
|
||||
Build the binary and image, seed a minimal configuration, start it:
|
||||
Seed a minimal configuration and start the published image. This is what the
|
||||
first release will make possible; it does not work today, because there is no
|
||||
image in the registry to pull:
|
||||
|
||||
```sh
|
||||
(cd web && npm ci && npm run build)
|
||||
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
|
||||
cd deploy/docker
|
||||
mkdir -p etc-nxdns
|
||||
cat > etc-nxdns/config.zon <<'EOF'
|
||||
@@ -37,11 +53,15 @@ cat > etc-nxdns/config.zon <<'EOF'
|
||||
.web = .{ .password = "choose-a-real-password" },
|
||||
}
|
||||
EOF
|
||||
docker compose up -d
|
||||
NXDNS_VERSION=<version> docker compose up -d
|
||||
```
|
||||
|
||||
DNS is on port 53, the web UI on <http://localhost:8080>. The config file
|
||||
seeds the database on first boot only; from then on the database is the
|
||||
The compose file defaults to `:latest`; pin a version for anything you intend
|
||||
to keep running. To run it before a release exists, build the image yourself and
|
||||
name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
|
||||
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md)
|
||||
describes. DNS is on port 53, the web UI on <http://localhost:8080>. The config
|
||||
file seeds the database on first boot only; from then on the database is the
|
||||
truth and changes go through the UI, the API, or `nxdns export` /
|
||||
`nxdns import`. Full install instructions, including the systemd path and
|
||||
the Pi 5 recipe, are in
|
||||
@@ -57,11 +77,32 @@ the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by
|
||||
```sh
|
||||
(cd web && npm ci && npm run build) # web UI -> web/dist
|
||||
zig build -Dweb-dist=web/dist # native binary -> zig-out/bin/nxdns
|
||||
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
# static x86_64 + aarch64 musl binaries
|
||||
zig build test --summary all # unit tests
|
||||
```
|
||||
|
||||
The release artifacts come out of the same build graph, so the whole release
|
||||
build runs on a laptop exactly as it runs on the CI runner:
|
||||
|
||||
```sh
|
||||
(cd web && npm ci && npm run build) # required: dist refuses the placeholder
|
||||
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 # tarballs -> zig-out/dist/
|
||||
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
|
||||
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # the release checks
|
||||
```
|
||||
|
||||
The version comes from `build.zig.zon` because `verify-dist` asserts the two
|
||||
agree; a tag sets both.
|
||||
|
||||
That is not a claim that your tarball will hash the same as a published one.
|
||||
Nothing in this project measures whether two builds of the same commit on two
|
||||
different machines land on the same bytes, so no document here describes the
|
||||
build as reproducible. The gate that would settle it is a recorded deferral —
|
||||
`specs/milestone-14.md` ruling 12 — and
|
||||
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what
|
||||
a matching or differing hash is worth in the meantime.
|
||||
|
||||
## Documentation
|
||||
|
||||
Start at [docs/README.md](docs/README.md), which splits the documentation
|
||||
@@ -71,6 +112,8 @@ into a tutorial, how-to guides, reference and explanation.
|
||||
a name, block a domain, on a scratch directory
|
||||
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) —
|
||||
a real install, including the Raspberry Pi 5
|
||||
- [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking
|
||||
the hashes and the signature before you install
|
||||
- [docs/reference/configuration.md](docs/reference/configuration.md) — every
|
||||
configuration field
|
||||
- [docs/reference/api.md](docs/reference/api.md) — REST API, auth and SSE
|
||||
@@ -80,3 +123,14 @@ into a tutorial, how-to guides, reference and explanation.
|
||||
map and design
|
||||
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and
|
||||
per-milestone contracts
|
||||
|
||||
## Licence
|
||||
|
||||
Copyright (c) 2026 Mokhtar Mial. nxdns is licensed under the European Union
|
||||
Public Licence v. 1.2 (`EUPL-1.2`); the full text is in [LICENSE](LICENSE).
|
||||
|
||||
Every released tarball and image carries a `THIRD-PARTY-NOTICES` file assembled
|
||||
from the reviewed inventory in [licenses/](licenses/), which covers what the
|
||||
artifacts actually contain: musl, the Zig runtime, SQLite, Mbed TLS and its
|
||||
vendored Everest and p256-m code, and the JavaScript and CSS bundled into the
|
||||
admin UI.
|
||||
|
||||
@@ -12,7 +12,7 @@ const cross_targets = [_][]const u8{
|
||||
"aarch64-linux-musl",
|
||||
};
|
||||
|
||||
/// The deploy target, read out of `cross_targets` so `test-aarch64` and `cross`
|
||||
/// The deploy target, read out of `cross_targets` so `test-aarch64` and `dist`
|
||||
/// cannot describe different machines. Reordering the array is caught here
|
||||
/// rather than by a qemu job that quietly ran the wrong architecture.
|
||||
const aarch64_triple = cross_targets[1];
|
||||
@@ -38,7 +38,11 @@ pub fn build(b: *std.Build) void {
|
||||
"contract-samples-out",
|
||||
"Absolute path the contract-sample generator writes instead of comparing",
|
||||
) orelse "";
|
||||
const version_string = b.option([]const u8, "version-string", "Version reported by `nxdns version`") orelse "0.1.0-dev";
|
||||
// `dist` requires this option (milestone-14 ruling 4): a release must carry
|
||||
// the tag's version, never a development default. Every other step keeps
|
||||
// the default, so `zig build` and `zig build test` need no flag.
|
||||
const version_option = b.option([]const u8, "version-string", "Version reported by `nxdns version` (required by `dist`)");
|
||||
const version_string = version_option orelse "0.1.0-dev";
|
||||
const git_commit = b.option([]const u8, "git-commit", "Git commit reported by `nxdns version`") orelse "unknown";
|
||||
const web_dist = b.option(
|
||||
[]const u8,
|
||||
@@ -119,7 +123,9 @@ pub fn build(b: *std.Build) void {
|
||||
//
|
||||
// No upstream issue matched a search of ziglang/zig for this behaviour;
|
||||
// the reference is the 0.16.0 source lines above. See AGENTS.md.
|
||||
const tests = addTestSuite(b, target, optimize, options, web_assets);
|
||||
const licenses_files = licensesFilesRoot(b);
|
||||
|
||||
const tests = addTestSuite(b, target, optimize, options, web_assets, licenses_files);
|
||||
const test_step = b.step("test", "Run the test suite");
|
||||
test_step.dependOn(&b.addRunArtifact(tests).step);
|
||||
|
||||
@@ -205,25 +211,263 @@ pub fn build(b: *std.Build) void {
|
||||
const aarch64_target = b.resolveTargetQuery(
|
||||
std.Target.Query.parse(.{ .arch_os_abi = aarch64_triple }) catch unreachable,
|
||||
);
|
||||
const aarch64_tests = addTestSuite(b, aarch64_target, optimize, options, web_assets);
|
||||
const aarch64_tests = addTestSuite(b, aarch64_target, optimize, options, web_assets, licenses_files);
|
||||
aarch64_tests.linkage = .static;
|
||||
const aarch64_run = b.addRunArtifact(aarch64_tests);
|
||||
aarch64_run.skip_foreign_checks = true;
|
||||
b.step("test-aarch64", "Run the test suite for aarch64-linux-musl (use -fqemu)")
|
||||
.dependOn(&aarch64_run.step);
|
||||
|
||||
const cross = b.step("cross", "Build static musl executables for every deploy target");
|
||||
addDist(b, options, web_assets, .{
|
||||
.version = version_option,
|
||||
.version_string = version_string,
|
||||
.git_commit = git_commit,
|
||||
.web_dist = web_dist,
|
||||
});
|
||||
}
|
||||
|
||||
/// Byte budgets from PLAN §18, asserted by `verify-dist` rather than by shell.
|
||||
const max_binary_bytes = 15_728_640;
|
||||
const max_asset_free_binary_bytes = 10_485_760;
|
||||
|
||||
/// The default of `-Dweb-dist`. A release built without the flag would ship the
|
||||
/// placeholder admin page, so `dist` refuses it (milestone-14 ruling 4). There
|
||||
/// is deliberately no override.
|
||||
const placeholder_web_dist = "web/dist-placeholder";
|
||||
|
||||
/// Repository files that go into the tarball verbatim. `nxdns.conf` is
|
||||
/// `sysusers.conf` under the name it is installed with, so nothing renames a
|
||||
/// file during install (milestone-14 ruling 4).
|
||||
const service_unit_path = "deploy/systemd/nxdns.service";
|
||||
const sysusers_path = "deploy/systemd/nxdns.conf";
|
||||
const license_path = "LICENSE";
|
||||
const install_md_path = "INSTALL.md";
|
||||
const dockerfile_path = "deploy/docker/Dockerfile";
|
||||
|
||||
/// The reviewed third-party inventory `dist` turns into `THIRD-PARTY-NOTICES`
|
||||
/// (milestone-14 ruling 3). It is committed and audited, never scraped.
|
||||
const licenses_dir = "licenses";
|
||||
const inventory_path = "licenses/inventory.zon";
|
||||
|
||||
const DistOptions = struct {
|
||||
/// `-Dversion-string` exactly as given, so a missing one is distinguishable
|
||||
/// from one that happens to equal the default.
|
||||
version: ?[]const u8,
|
||||
version_string: []const u8,
|
||||
git_commit: []const u8,
|
||||
web_dist: []const u8,
|
||||
};
|
||||
|
||||
/// `dist` builds everything releasable; `verify-dist` asserts the result.
|
||||
/// Both run on a laptop exactly as they run on the runner, which is the point:
|
||||
/// release checks that only exist in CI shell are the brittleness milestone 14
|
||||
/// set out to remove.
|
||||
fn addDist(
|
||||
b: *std.Build,
|
||||
options: *std.Build.Step.Options,
|
||||
web_assets: std.Build.LazyPath,
|
||||
dist_options: DistOptions,
|
||||
) void {
|
||||
const dist_step = b.step("dist", "Build the release tarballs, checksums and staged payloads");
|
||||
const verify_step = b.step("verify-dist", "Verify the release artifacts under zig-out/dist");
|
||||
|
||||
if (distPreflight(b, dist_options)) |problem| {
|
||||
const fail = b.addFail(problem);
|
||||
dist_step.dependOn(&fail.step);
|
||||
verify_step.dependOn(&fail.step);
|
||||
return;
|
||||
}
|
||||
|
||||
const stage_tool = hostTool(b, "dist_stage");
|
||||
|
||||
// A Run step hashes only the resolved path string of a directory argument,
|
||||
// not its contents, so the inventory is staged through WriteFiles first:
|
||||
// the staged copy lives at a content-hashed path, and editing a licence
|
||||
// text re-runs the staging tool instead of replaying a stale cache.
|
||||
const licenses_stage = b.addWriteFiles();
|
||||
const staged_licenses = licenses_stage.addCopyDirectory(b.path(licenses_dir), ".", .{});
|
||||
|
||||
const sums_run = b.addRunArtifact(stage_tool);
|
||||
sums_run.addArg("sums");
|
||||
sums_run.addArg("--out");
|
||||
const sums_file = sums_run.addOutputFileArg("SHA256SUMS");
|
||||
|
||||
const verify_tool = hostTool(b, "verify_dist");
|
||||
const verify_run = b.addRunArtifact(verify_tool);
|
||||
// A verifier that a cache can replay is not a verifier. This also makes the
|
||||
// tool's report reach the terminal instead of a captured pipe.
|
||||
verify_run.has_side_effects = true;
|
||||
verify_run.addArgs(&.{ "--dist-dir", b.getInstallPath(.prefix, "dist") });
|
||||
verify_run.addArgs(&.{ "--work-dir", b.getInstallPath(.prefix, "dist-verify") });
|
||||
verify_run.addArgs(&.{ "--version", dist_options.version_string });
|
||||
verify_run.addArgs(&.{ "--git-commit", dist_options.git_commit });
|
||||
verify_run.addArg("--zon");
|
||||
verify_run.addFileArg(b.path("build.zig.zon"));
|
||||
verify_run.addArgs(&.{ "--max-bytes", b.fmt("{d}", .{max_binary_bytes}) });
|
||||
verify_run.addArgs(&.{ "--asset-free-max-bytes", b.fmt("{d}", .{max_asset_free_binary_bytes}) });
|
||||
verify_run.addArgs(&.{ "--host-arch", @tagName(b.graph.host.result.cpu.arch) });
|
||||
if (b.enable_qemu) verify_run.addArg("--qemu");
|
||||
|
||||
// The asset-free budget gets its own build against a generated empty assets
|
||||
// directory (milestone-14 ruling 5). Not the placeholder: ruling 4 makes
|
||||
// that unbuildable, and re-admitting it for one size check through a back
|
||||
// door would defeat the point of the refusal.
|
||||
const empty_web_assets = webAssetsIndex(b, b.addWriteFiles().getDirectory(), null);
|
||||
|
||||
for (cross_targets) |triple| {
|
||||
const query = std.Target.Query.parse(.{ .arch_os_abi = triple }) catch |err| {
|
||||
std.debug.panic("invalid cross target '{s}': {t}", .{ triple, err });
|
||||
};
|
||||
const cross_exe = addExecutable(b, b.resolveTargetQuery(query), optimize, options, web_assets);
|
||||
cross_exe.linkage = .static;
|
||||
const install = b.addInstallArtifact(cross_exe, .{
|
||||
.dest_dir = .{ .override = .{ .custom = b.fmt("cross/{s}", .{triple}) } },
|
||||
const target = b.resolveTargetQuery(query);
|
||||
const name = b.fmt("nxdns-{s}-{s}", .{ dist_options.version_string, triple });
|
||||
|
||||
// ReleaseSafe is not read from `-Doptimize`: the release artifact must
|
||||
// not change shape because a flag was forgotten. `.strip` is
|
||||
// `std.Build.Module.strip`, which emits `-fstrip` (Module.zig:545), so
|
||||
// no objcopy and no binutils-aarch64-linux-gnu on the runner.
|
||||
const exe = addExecutable(b, target, .ReleaseSafe, options, web_assets);
|
||||
exe.linkage = .static;
|
||||
exe.root_module.strip = true;
|
||||
|
||||
const stage_run = b.addRunArtifact(stage_tool);
|
||||
stage_run.addArg("stage");
|
||||
stage_run.addArg("--out");
|
||||
const staged = stage_run.addOutputDirectoryArg(name);
|
||||
stage_run.addArg("--binary");
|
||||
stage_run.addFileArg(exe.getEmittedBin());
|
||||
stage_run.addArg("--service");
|
||||
stage_run.addFileArg(b.path(service_unit_path));
|
||||
stage_run.addArg("--sysusers");
|
||||
stage_run.addFileArg(b.path(sysusers_path));
|
||||
stage_run.addArg("--license");
|
||||
stage_run.addFileArg(b.path(license_path));
|
||||
stage_run.addArg("--install-md");
|
||||
stage_run.addFileArg(b.path(install_md_path));
|
||||
stage_run.addArg("--licenses");
|
||||
stage_run.addDirectoryArg(staged_licenses);
|
||||
|
||||
// Two commands, never one: `addSystemCommand` executes argv directly
|
||||
// and does not interpret `|`, and a shell pipeline without `pipefail`
|
||||
// would report only gzip's status while a failed tar passed silently.
|
||||
const tar_run = b.addSystemCommand(&.{
|
||||
"tar",
|
||||
"--format=gnu",
|
||||
"--sort=name",
|
||||
"--mtime=@0",
|
||||
"--owner=0",
|
||||
"--group=0",
|
||||
"--numeric-owner",
|
||||
"-c",
|
||||
"-f",
|
||||
});
|
||||
cross.dependOn(&install.step);
|
||||
setReproducibleEnv(tar_run);
|
||||
const tar_file = tar_run.addOutputFileArg(b.fmt("{s}.tar", .{name}));
|
||||
tar_run.addArg("-C");
|
||||
// The staged payload is the sole entry of its cache directory, so its
|
||||
// parent is what `-C` needs and declaring it declares the payload.
|
||||
tar_run.addDirectoryArg(staged.dirname());
|
||||
tar_run.addArg(name);
|
||||
|
||||
// `-n` is required because `--mtime=@0` normalises the tar member times
|
||||
// but not the timestamp gzip writes into its own header. `-c` is
|
||||
// required because plain `gzip <file>` rewrites its input in place, and
|
||||
// the input here is a content-addressed cache entry.
|
||||
const gzip_run = b.addSystemCommand(&.{ "gzip", "-n", "-9", "-c" });
|
||||
setReproducibleEnv(gzip_run);
|
||||
gzip_run.addFileArg(tar_file);
|
||||
const tarball = gzip_run.captureStdOut(.{ .basename = b.fmt("{s}.tar.gz", .{name}) });
|
||||
|
||||
const install_binary = b.addInstallFile(
|
||||
staged.path(b, "nxdns"),
|
||||
b.fmt("dist/bin/{s}/nxdns", .{triple}),
|
||||
);
|
||||
const install_stage = b.addInstallDirectory(.{
|
||||
.source_dir = staged,
|
||||
.install_dir = .prefix,
|
||||
.install_subdir = b.fmt("dist/stage/{s}", .{name}),
|
||||
});
|
||||
const install_tarball = b.addInstallFile(tarball, b.fmt("dist/{s}.tar.gz", .{name}));
|
||||
dist_step.dependOn(&install_binary.step);
|
||||
dist_step.dependOn(&install_stage.step);
|
||||
dist_step.dependOn(&install_tarball.step);
|
||||
|
||||
// The checksum file covers the two tarballs and nothing else. It cannot
|
||||
// cover the container image: that digest does not exist until buildx
|
||||
// has pushed, which happens later and elsewhere.
|
||||
sums_run.addArg("--entry");
|
||||
sums_run.addArg(b.fmt("{s}.tar.gz", .{name}));
|
||||
sums_run.addFileArg(tarball);
|
||||
|
||||
const asset_free_exe = addExecutable(b, target, .ReleaseSafe, options, empty_web_assets);
|
||||
asset_free_exe.linkage = .static;
|
||||
asset_free_exe.root_module.strip = true;
|
||||
|
||||
verify_run.addArgs(&.{ "--archive", triple, b.fmt("{s}.tar.gz", .{name}) });
|
||||
verify_run.addArgs(&.{ "--asset-free", triple });
|
||||
verify_run.addFileArg(asset_free_exe.getEmittedBin());
|
||||
}
|
||||
|
||||
const install_sums = b.addInstallFile(sums_file, "dist/SHA256SUMS");
|
||||
dist_step.dependOn(&install_sums.step);
|
||||
|
||||
verify_run.step.dependOn(dist_step);
|
||||
verify_step.dependOn(&verify_run.step);
|
||||
}
|
||||
|
||||
/// The one message `dist` and `verify-dist` fail with when the release inputs
|
||||
/// are not there, or null when they are. Returning it rather than calling
|
||||
/// `std.process.fatal` keeps `zig build` and `zig build test` working: the
|
||||
/// checks gate only the two release steps.
|
||||
fn distPreflight(b: *std.Build, dist_options: DistOptions) ?[]const u8 {
|
||||
var problems: std.ArrayList([]const u8) = .empty;
|
||||
|
||||
if (dist_options.version == null) {
|
||||
problems.append(b.allocator, "-Dversion-string=<version> is required by `dist` " ++
|
||||
"(the release tag without its leading `v`); it has no default here.") catch @panic("OOM");
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, b.pathFromRoot(dist_options.web_dist), b.pathFromRoot(placeholder_web_dist))) {
|
||||
problems.append(b.allocator, "-Dweb-dist resolves to " ++ placeholder_web_dist ++
|
||||
", which a release must never ship. Build the real admin UI " ++
|
||||
"(cd web && npm ci && npm run build) and pass -Dweb-dist=web/dist.") catch @panic("OOM");
|
||||
}
|
||||
|
||||
for ([_][]const u8{
|
||||
service_unit_path,
|
||||
sysusers_path,
|
||||
license_path,
|
||||
install_md_path,
|
||||
inventory_path,
|
||||
}) |path| {
|
||||
_ = b.build_root.handle.statFile(b.graph.io, path, .{}) catch |err| {
|
||||
problems.append(b.allocator, b.fmt(
|
||||
"`dist` needs '{s}', which is not readable: {t}",
|
||||
.{ path, err },
|
||||
)) catch @panic("OOM");
|
||||
};
|
||||
}
|
||||
|
||||
if (problems.items.len == 0) return null;
|
||||
return std.mem.join(b.allocator, "\n", problems.items) catch @panic("OOM");
|
||||
}
|
||||
|
||||
/// A build-time helper compiled for the host: `tools/<name>.zig`.
|
||||
fn hostTool(b: *std.Build, name: []const u8) *std.Build.Step.Compile {
|
||||
return b.addExecutable(.{
|
||||
.name = name,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path(b.fmt("tools/{s}.zig", .{name})),
|
||||
.target = b.graph.host,
|
||||
.optimize = .ReleaseSafe,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/// Locale and time zone leak into archive metadata and into tool output.
|
||||
/// Pinning both is the cheap half of reproducibility (milestone-14 ruling 12).
|
||||
fn setReproducibleEnv(run: *std.Build.Step.Run) void {
|
||||
run.setEnvironmentVariable("LC_ALL", "C");
|
||||
run.setEnvironmentVariable("TZ", "UTC");
|
||||
}
|
||||
|
||||
/// Milestone-15 ruling 4: every `*.zig` under `src/` must appear in
|
||||
@@ -282,6 +526,7 @@ fn addTestSuite(
|
||||
optimize: std.builtin.OptimizeMode,
|
||||
options: *std.Build.Step.Options,
|
||||
web_assets: std.Build.LazyPath,
|
||||
licenses_files: std.Build.LazyPath,
|
||||
) *std.Build.Step.Compile {
|
||||
const tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
@@ -308,9 +553,29 @@ fn addTestSuite(
|
||||
.root_source_file = b.path("web/src/lib/contract_samples.zig"),
|
||||
});
|
||||
tests.root_module.addAnonymousImport("web_assets", .{ .root_source_file = web_assets });
|
||||
tests.root_module.addAnonymousImport("licenses_files", .{ .root_source_file = licenses_files });
|
||||
return tests;
|
||||
}
|
||||
|
||||
/// The root of the `licenses_files` module (milestone-14 ruling 3), through
|
||||
/// which `src/licenses_drift_test.zig` embeds the licence inventory and the two
|
||||
/// dependency manifests whose identity it guards.
|
||||
///
|
||||
/// A module cannot embed anything above its own root directory, so the trees
|
||||
/// are merged into one WriteFiles directory: `licenses/` at the root, plus
|
||||
/// copies of `build.zig.zon`, `web/package-lock.json` and the image Dockerfile
|
||||
/// beside it. The module root is the copy of `licenses/licenses.zig`, which is
|
||||
/// why that file's `@embedFile` paths name files that do not sit beside it in
|
||||
/// the repository.
|
||||
fn licensesFilesRoot(b: *std.Build) std.Build.LazyPath {
|
||||
const stage = b.addWriteFiles();
|
||||
_ = stage.addCopyDirectory(b.path("licenses"), ".", .{});
|
||||
_ = stage.addCopyFile(b.path("build.zig.zon"), "build.zig.zon");
|
||||
_ = stage.addCopyFile(b.path("web/package-lock.json"), "package-lock.json");
|
||||
_ = stage.addCopyFile(b.path(dockerfile_path), "Dockerfile");
|
||||
return stage.getDirectory().path(b, "licenses.zig");
|
||||
}
|
||||
|
||||
/// One fuzz test artifact: a module rooted at a `tests/fuzz/` file plus the one
|
||||
/// named import through which that target reaches the code under test. The fuzz
|
||||
/// suites cannot share `addTestSuite` — they take no `build_options`, no
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.{
|
||||
.name = .nxdns,
|
||||
.version = "0.1.0",
|
||||
.version = "0.0.1",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.paths = .{""},
|
||||
.fingerprint = 0x3307b311dded1d91,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# The Dockerfile copies only zig-out/cross out of the repository-root context,
|
||||
# and BuildKit transfers only referenced paths. This file guards the classic
|
||||
# (non-BuildKit) builder, which would otherwise send the whole tree: copy it to
|
||||
# the repository root as .dockerignore before building without BuildKit.
|
||||
*
|
||||
!zig-out/cross
|
||||
+54
-24
@@ -1,39 +1,69 @@
|
||||
# The binary is NOT compiled here. Build it first, from the repository root:
|
||||
#
|
||||
# (cd web && npm ci && npm run build)
|
||||
# zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
# zig build dist -Dversion-string=<V> -Dgit-commit=<SHA> \
|
||||
# -Dweb-dist=web/dist -Doptimize=ReleaseSafe
|
||||
#
|
||||
# then build the image with the repository root as context:
|
||||
#
|
||||
# docker build -t nxdns -f deploy/docker/Dockerfile .
|
||||
#
|
||||
# The builder stage stages the CA bundle (upstream DoH/DoT verification rescans
|
||||
# the system store; a scratch image without one breaks every TLS upstream) and
|
||||
# maps the buildx TARGETARCH onto the zig cross-target directory. The legacy
|
||||
# builder leaves TARGETARCH empty, so the arch falls back to the build host's
|
||||
# `uname -m`: a plain `docker build` must never package a foreign binary that
|
||||
# only fails at `docker run` with exec-format.
|
||||
# The builder stage only copies files, so it is pinned to $BUILDPLATFORM: the
|
||||
# arm64 image is assembled natively and needs no qemu under buildx. That makes
|
||||
# BuildKit a requirement, not a preference. `DOCKER_BUILDKIT=0` fails at the
|
||||
# first FROM, because the classic builder defines no BUILDPLATFORM and rejects
|
||||
# the empty `--platform=`. Do not "fix" that by declaring
|
||||
# `ARG BUILDPLATFORM=<default>`: a declared default shadows BuildKit's built-in
|
||||
# and silently drags the builder stage back under emulation on cross builds.
|
||||
#
|
||||
# It stages the CA bundle that the pinned base already ships (upstream DoH/DoT
|
||||
# verification rescans the system store; a scratch image without one breaks
|
||||
# every TLS upstream) and maps TARGETARCH onto the zig target triple. A builder
|
||||
# that leaves TARGETARCH empty falls back to the build host's `uname -m`: no
|
||||
# build may package a foreign binary that only fails at `docker run` with
|
||||
# exec-format.
|
||||
|
||||
FROM alpine:3.22 AS builder
|
||||
RUN apk add --no-cache ca-certificates
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce AS builder
|
||||
ARG TARGETARCH
|
||||
COPY zig-out/cross /cross
|
||||
RUN mkdir -p /rootfs/etc/ssl/certs /rootfs/etc/nxdns /rootfs/var/lib/nxdns \
|
||||
&& cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/ \
|
||||
&& arch="${TARGETARCH:-}" \
|
||||
&& if [ -z "$arch" ]; then case "$(uname -m)" in \
|
||||
x86_64) arch=amd64 ;; \
|
||||
aarch64) arch=arm64 ;; \
|
||||
*) echo "unsupported build host $(uname -m); use buildx" >&2; exit 1 ;; \
|
||||
esac; fi \
|
||||
&& case "$arch" in \
|
||||
amd64) cp /cross/x86_64-linux-musl/nxdns /rootfs/nxdns ;; \
|
||||
arm64) cp /cross/aarch64-linux-musl/nxdns /rootfs/nxdns ;; \
|
||||
*) echo "unsupported TARGETARCH '${TARGETARCH}'" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& chown 65532:65532 /rootfs/var/lib/nxdns
|
||||
COPY zig-out/dist/bin /dist/bin
|
||||
COPY zig-out/dist/stage /dist/stage
|
||||
RUN set -eu; \
|
||||
mkdir -p /rootfs/etc/ssl/certs /rootfs/etc/nxdns /rootfs/var/lib/nxdns; \
|
||||
cp /etc/ssl/certs/ca-certificates.crt /rootfs/etc/ssl/certs/; \
|
||||
arch="${TARGETARCH:-}"; \
|
||||
if [ -z "$arch" ]; then \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) arch=amd64 ;; \
|
||||
aarch64) arch=arm64 ;; \
|
||||
*) echo "unsupported build host $(uname -m); use buildx" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
case "$arch" in \
|
||||
amd64) triple=x86_64-linux-musl ;; \
|
||||
arm64) triple=aarch64-linux-musl ;; \
|
||||
*) echo "unsupported TARGETARCH '${TARGETARCH}'" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
cp "/dist/bin/$triple/nxdns" /rootfs/nxdns; \
|
||||
chmod 0755 /rootfs/nxdns; \
|
||||
stage=$(find /dist/stage -mindepth 1 -maxdepth 1 -type d -name "nxdns-*-$triple"); \
|
||||
if [ "$(printf '%s' "$stage" | grep -c '^')" != 1 ]; then \
|
||||
echo "expected exactly one /dist/stage dir for $triple, found: $stage" >&2; exit 1; \
|
||||
fi; \
|
||||
cp "$stage/LICENSE" "$stage/THIRD-PARTY-NOTICES" /rootfs/; \
|
||||
chmod 0644 /rootfs/LICENSE /rootfs/THIRD-PARTY-NOTICES; \
|
||||
chown 65532:65532 /rootfs/var/lib/nxdns
|
||||
|
||||
FROM scratch
|
||||
ARG VERSION=0.0.0-dev
|
||||
ARG REVISION=unknown
|
||||
ARG CREATED=1970-01-01T00:00:00Z
|
||||
LABEL org.opencontainers.image.source="https://git.mial.net/mokhtar/nxdns" \
|
||||
org.opencontainers.image.revision="$REVISION" \
|
||||
org.opencontainers.image.version="$VERSION" \
|
||||
org.opencontainers.image.licenses="EUPL-1.2" \
|
||||
org.opencontainers.image.created="$CREATED" \
|
||||
org.opencontainers.image.title="nxdns" \
|
||||
org.opencontainers.image.description="Self-hosted DNS sinkhole for a household LAN"
|
||||
COPY --from=builder /rootfs/ /
|
||||
USER 65532:65532
|
||||
VOLUME /var/lib/nxdns
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# BuildKit reads this file because it sits next to the Dockerfile named by
|
||||
# `-f deploy/docker/Dockerfile`; the build context is the repository root, so
|
||||
# without it the whole worktree — .git, node_modules, .zig-cache — is sent to
|
||||
# the daemon. The classic builder reads only a context-root .dockerignore, but
|
||||
# it cannot build this Dockerfile at all (see the BUILDPLATFORM note there), so
|
||||
# there is nothing to copy anywhere.
|
||||
*
|
||||
!zig-out/dist/bin
|
||||
!zig-out/dist/stage
|
||||
@@ -1,9 +1,10 @@
|
||||
services:
|
||||
nxdns:
|
||||
image: nxdns
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile
|
||||
# Published multi-architecture image. Pin an exact version in production:
|
||||
# NXDNS_VERSION=0.0.1 docker compose up -d. To run a locally built image
|
||||
# instead, build it first (see deploy/docker/Dockerfile) and set
|
||||
# NXDNS_IMAGE=nxdns.
|
||||
image: ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}
|
||||
restart: unless-stopped
|
||||
# First boot needs ./etc-nxdns/config.zon with a `default` group and at
|
||||
# least one enabled upstream, or the container exits with code 2. The file
|
||||
|
||||
+5
-3
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
The identity of the dependency sets that decide what licenses/inventory.zon has
|
||||
to cover. This file is a snapshot, not a source: src/licenses_drift_test.zig
|
||||
recomputes each section from build.zig.zon and web/package-lock.json and fails
|
||||
when it disagrees with what is recorded here. Prose before the first section
|
||||
header is ignored by the guard.
|
||||
|
||||
Updating a section without reviewing licenses/inventory.zon defeats the point of
|
||||
the guard. When a section changes, work out what the change means for the
|
||||
shipped bytes first, then record it.
|
||||
|
||||
The runtime closure is every lockfile package not marked `dev`. That is a
|
||||
superset of what the bundler emits — the guard cannot run rollup — so packages
|
||||
tree-shaken out of web/dist appear here too; the inventory explains each of them
|
||||
instead of carrying its licence.
|
||||
|
||||
The bundled-packages section is the one the Zig guard cannot compute: it is the
|
||||
set of npm packages that actually contribute a module to web/dist, read off the
|
||||
sourcemap `sources` lists of a `vite build --sourcemap`. Only the frontend gate
|
||||
can produce it, so gates.yml recomputes it there and fails on a difference. It
|
||||
is what closes the hole the runtime-closure section leaves open — a package
|
||||
already in the closure that application code starts importing changes no
|
||||
lockfile and no version, but it does change this list.
|
||||
|
||||
The base image section is here for the same reason: the container image is a
|
||||
released artifact, and the one third-party file it carries that the binary does
|
||||
not — the Mozilla CA bundle — comes out of that base. A new tag or a rebuilt
|
||||
digest both change the line, which forces somebody to re-read what the new base
|
||||
ships before the inventory can claim it.
|
||||
|
||||
The generators section is the other direction: packages the lockfile marks `dev`
|
||||
whose own output nevertheless ends up in web/dist, and so in the binary. Its
|
||||
membership is a judgement, not a query — it was settled by reading a
|
||||
`vite build --sourcemap` of web/, both the sourcemap `sources` lists and the
|
||||
regions of each chunk no sourcemap segment maps back to a source file. The
|
||||
versions here are read from the lockfile like any other section.
|
||||
|
||||
[build.zig.zon dependencies]
|
||||
mbedtls url=https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/mbedtls-3.6.7.tar.gz hash=N-V-__8AALrvlQKVtYlvv9dpBnbrJfdwR_F0wAgwsvZhAF1Y
|
||||
sqlite url=https://sqlite.org/2026/sqlite-amalgamation-3530400.zip hash=N-V-__8AAGVtrgCcOcmjrOJnagmnRyMrcKaOo09KbU-vu8w8
|
||||
|
||||
[npm runtime closure]
|
||||
@tanstack/history 1.162.0 MIT
|
||||
@tanstack/query-core 5.101.4 MIT
|
||||
@tanstack/react-query 5.101.4 MIT
|
||||
@tanstack/react-router 1.170.18 MIT
|
||||
@tanstack/react-store 0.9.3 MIT
|
||||
@tanstack/router-core 1.171.15 MIT
|
||||
@tanstack/store 0.9.3 MIT
|
||||
cookie-es 3.1.1 MIT
|
||||
isbot 5.2.1 Unlicense
|
||||
react 19.2.8 MIT
|
||||
react-dom 19.2.8 MIT
|
||||
scheduler 0.27.0 MIT
|
||||
seroval 1.5.6 MIT
|
||||
seroval-plugins 1.5.6 MIT
|
||||
use-sync-external-store 1.6.0 MIT
|
||||
|
||||
[npm build-time generators whose output ships]
|
||||
rolldown 1.1.5 MIT
|
||||
tailwindcss 4.3.3 MIT
|
||||
vite 8.1.5 MIT
|
||||
|
||||
[container base image]
|
||||
alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
|
||||
[npm packages bundled into web/dist]
|
||||
@tanstack/history
|
||||
@tanstack/query-core
|
||||
@tanstack/react-query
|
||||
@tanstack/react-router
|
||||
@tanstack/react-store
|
||||
@tanstack/router-core
|
||||
@tanstack/store
|
||||
react
|
||||
react-dom
|
||||
scheduler
|
||||
use-sync-external-store
|
||||
@@ -0,0 +1,19 @@
|
||||
Files from Project Everest, vendored in the Mbed TLS 3.6.7 source tree under
|
||||
3rdparty/everest/ and compiled into every nxdns binary.
|
||||
|
||||
Copyright 2016-2018 INRIA and Microsoft Corporation
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
these files except in compliance with the License. You may obtain a copy of the
|
||||
License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
|
||||
The full text of the Apache License, Version 2.0 is reproduced above, in the
|
||||
Mbed TLS section of this file.
|
||||
@@ -0,0 +1,127 @@
|
||||
// The third-party licence inventory: what a released nxdns artifact actually
|
||||
// contains, in the order `zig build dist` concatenates it into
|
||||
// THIRD-PARTY-NOTICES. Hand-written and reviewed on purpose — a notices file
|
||||
// scraped from the dependency tree at build time rots into a false statement
|
||||
// without anyone noticing (milestone-14 ruling 3).
|
||||
//
|
||||
// "Artifact" means the two tarballs *and* the container image. The image is
|
||||
// built FROM scratch and carries one third-party file the binary does not
|
||||
// contain, the Mozilla CA bundle; distributing the image is distribution, so
|
||||
// that file is inventoried here like anything else. licenses/preamble.txt says
|
||||
// so at the head of the assembled notices.
|
||||
//
|
||||
// Each entry: the component, the version(s) actually shipped, why it is here
|
||||
// and which licence option was taken, and the file under licenses/ holding the
|
||||
// licence text. tools/dist_stage.zig parses exactly this shape and
|
||||
// src/licenses_drift_test.zig type-checks the same file, so the two must agree.
|
||||
//
|
||||
// Order matters. The full Apache License 2.0 text is carried once, by the
|
||||
// Mbed TLS entry; the Project Everest and p256-m entries that follow refer back
|
||||
// to it. src/licenses_drift_test.zig enforces that ordering, and fails when the
|
||||
// dependency sets change without a matching change under licenses/.
|
||||
//
|
||||
// For npm packages the `.version` field spells `<package> <version>` for every
|
||||
// package the entry covers. src/licenses_drift_test.zig compares those pairs
|
||||
// against licenses/dependency-identity.txt, so a version bump that touches only
|
||||
// the identity file fails.
|
||||
//
|
||||
// The web entries are the packages whose code is actually in web/dist, read off
|
||||
// a `vite build --sourcemap`: the sourcemap `sources` lists give the packages
|
||||
// whose modules were bundled, and the regions of each chunk that *no* sourcemap
|
||||
// segment maps back to a source file give the code the toolchain injected on
|
||||
// its own. That second half is how vite and rolldown got here — neither appears
|
||||
// in any `sources` list, and both emit their own runtime source verbatim.
|
||||
//
|
||||
// Four packages of the lockfile's non-dev closure contribute no module to any
|
||||
// chunk and are therefore not inventoried: cookie-es, isbot, seroval,
|
||||
// seroval-plugins — all four reach the tree only through @tanstack/router-core's
|
||||
// server-side paths, which this SPA never imports, and rollup drops them. They
|
||||
// stay listed in licenses/dependency-identity.txt so that a future build which
|
||||
// does pull them in trips the guard.
|
||||
//
|
||||
// Of the remaining devDependencies, none puts a byte in web/dist: @vitejs/
|
||||
// plugin-react and typescript only transform our own sources (react-refresh is
|
||||
// dev-server only, and tslib is optional and unused), @tailwindcss/vite
|
||||
// orchestrates tailwindcss without contributing CSS of its own, lightningcss
|
||||
// only minifies, and @testing-library/*, jsdom, vitest, oxlint and prettier
|
||||
// never touch the build output.
|
||||
.{
|
||||
.{
|
||||
.component = "musl libc",
|
||||
.version = "1.2.5 (as bundled with Zig 0.16.0)",
|
||||
.note = "Statically linked into both released binaries: every nxdns build targets *-linux-musl with .linkage = .static. In the tarballs and in the image.",
|
||||
.file = "musl-copyright.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Zig standard library, compiler-rt and runtime",
|
||||
.version = "0.16.0",
|
||||
.note = "The standard library, compiler-rt and the safety runtime are compiled into the binary, so the Zig licence travels with the artifact and not only with the toolchain. Parts of Zig's compiler-rt are ported from LLVM's compiler-rt, which the LLVM project licenses under Apache-2.0 WITH LLVM-exception; that exception waives Sections 4(a), 4(b) and 4(d) of the Apache licence for portions embedded in object form, which is the only form nxdns ships, so no further LLVM notice is required here. Zig redistributes those ports under the MIT text below. In the tarballs and in the image.",
|
||||
.file = "zig-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "SQLite",
|
||||
.version = "3.53.4",
|
||||
.note = "Vendored amalgamation, statically linked. Public domain: listed for completeness, no obligation attaches. In the tarballs and in the image.",
|
||||
.file = "sqlite-public-domain.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Mbed TLS",
|
||||
.version = "3.6.7",
|
||||
.note = "Vendored, statically linked. Dual Apache-2.0 OR GPL-2.0-or-later; nxdns takes the Apache-2.0 option, and the full Apache-2.0 text is reproduced below. In the tarballs and in the image.",
|
||||
.file = "mbedtls-apache-2.0.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Project Everest (Curve25519, HACL*)",
|
||||
.version = "as vendored in Mbed TLS 3.6.7 (3rdparty/everest)",
|
||||
.note = "Compiled in through build.zig's mbedtls_3rdparty_sources even though the stock config leaves MBEDTLS_ECDH_VARIANT_EVEREST_ENABLED off: the object code is in the binary either way.",
|
||||
.file = "everest-apache-2.0.txt",
|
||||
},
|
||||
.{
|
||||
.component = "p256-m",
|
||||
.version = "as vendored in Mbed TLS 3.6.7 (3rdparty/p256-m)",
|
||||
.note = "Compiled in through build.zig's mbedtls_3rdparty_sources even though the P-256 driver is not enabled. Dual Apache-2.0 OR GPL-2.0-or-later; nxdns takes the Apache-2.0 option.",
|
||||
.file = "p256-m-apache-2.0.txt",
|
||||
},
|
||||
.{
|
||||
.component = "React (react, react-dom, scheduler, use-sync-external-store)",
|
||||
.version = "react 19.2.8, react-dom 19.2.8, scheduler 0.27.0, use-sync-external-store 1.6.0",
|
||||
.note = "Bundled into the admin UI JavaScript that is embedded in the binary. All four ship under the same Meta MIT text.",
|
||||
.file = "react-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "TanStack Query, Router and History",
|
||||
.version = "@tanstack/react-query 5.101.4, @tanstack/query-core 5.101.4, @tanstack/react-router 1.170.18, @tanstack/router-core 1.171.15, @tanstack/history 1.162.0",
|
||||
.note = "Bundled into the admin UI JavaScript. Grouped because all five carry the identical MIT text and copyright line.",
|
||||
.file = "tanstack-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "TanStack Store",
|
||||
.version = "@tanstack/store 0.9.3, @tanstack/react-store 0.9.3",
|
||||
.note = "Bundled into the admin UI JavaScript. Separate entry from the other TanStack packages: same MIT text, different copyright line.",
|
||||
.file = "tanstack-store-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Tailwind CSS",
|
||||
.version = "tailwindcss 4.3.3",
|
||||
.note = "A devDependency whose output ships: the admin UI stylesheet is generated from Tailwind's own CSS sources, so the generated CSS in the binary is a derivative of them.",
|
||||
.file = "tailwindcss-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Vite",
|
||||
.version = "vite 8.1.5",
|
||||
.note = "A devDependency whose own runtime source ships: the preload helper and the modulepreload polyfill are injected verbatim into web/dist/assets/index-*.js, which is embedded in the binary.",
|
||||
.file = "vite-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Rolldown",
|
||||
.version = "rolldown 1.1.5",
|
||||
.note = "A devDependency whose own runtime source ships: the bundler Vite 8 runs prepends its CommonJS interop helpers verbatim to web/dist/assets/classes-*.js, which is embedded in the binary.",
|
||||
.file = "rolldown-mit.txt",
|
||||
},
|
||||
.{
|
||||
.component = "Mozilla CA certificate bundle (Alpine ca-certificates-bundle)",
|
||||
.version = "20260611-r0 (alpine 3.22)",
|
||||
.note = "In the container image only, at /etc/ssl/certs/ca-certificates.crt: deploy/docker/Dockerfile copies it out of the pinned base into the scratch image so DoH and DoT upstreams verify. apk records the package as MPL-2.0 AND MIT; the shipped bytes derive from Mozilla NSS certdata.txt under MPL-2.0, whose full text follows. Not in the tarballs and not in the binary.",
|
||||
.file = "mozilla-ca-bundle-mpl-2.0.txt",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Module root for the `licenses_files` anonymous import (test builds only).
|
||||
//! `@embedFile` paths resolve relative to this file, and a module cannot embed
|
||||
//! anything above its own root directory, so `build.zig` stages a directory
|
||||
//! holding a copy of `licenses/` alongside copies of `build.zig.zon` and
|
||||
//! `web/package-lock.json`, and roots the import at the copy of this file.
|
||||
//! That is why the two decls below name files that do not sit beside this one
|
||||
//! in the repository.
|
||||
//!
|
||||
//! Everything here is embedded at compile time: `src/licenses_drift_test.zig`
|
||||
//! reads no files and shells out to nothing.
|
||||
|
||||
/// The reviewed inventory `zig build dist` turns into THIRD-PARTY-NOTICES.
|
||||
pub const inventory_zon = @embedFile("inventory.zon");
|
||||
|
||||
/// The head of the assembled THIRD-PARTY-NOTICES: the paragraph that states
|
||||
/// which artifacts the file covers. It is data rather than a string literal in
|
||||
/// `tools/dist_stage.zig` because the scope statement is part of the notice,
|
||||
/// and the notice is reviewed here.
|
||||
pub const preamble_txt = @embedFile("preamble.txt");
|
||||
|
||||
/// The recorded identity of the dependency sets the inventory must cover.
|
||||
pub const dependency_identity_txt = @embedFile("dependency-identity.txt");
|
||||
|
||||
/// Repository root `build.zig.zon`, the identity of the Zig dependency set.
|
||||
pub const build_zig_zon = @embedFile("build.zig.zon");
|
||||
|
||||
/// `web/package-lock.json`, the identity of the npm closure.
|
||||
pub const package_lock_json = @embedFile("package-lock.json");
|
||||
|
||||
/// `deploy/docker/Dockerfile`, the identity of the base image. The image is a
|
||||
/// released artifact and the only third-party file it carries that the binary
|
||||
/// does not — the CA bundle — comes out of that base, so a base bump is a
|
||||
/// licence-inventory event like any dependency bump.
|
||||
pub const dockerfile = @embedFile("Dockerfile");
|
||||
|
||||
pub const Text = struct {
|
||||
/// The name an inventory entry's `.file` field must use.
|
||||
name: []const u8,
|
||||
body: []const u8,
|
||||
};
|
||||
|
||||
/// Every licence text under `licenses/`. An entry here that no inventory entry
|
||||
/// names, or an inventory entry naming a file absent here, fails the guard.
|
||||
pub const texts: []const Text = &.{
|
||||
.{ .name = "musl-copyright.txt", .body = @embedFile("musl-copyright.txt") },
|
||||
.{ .name = "zig-mit.txt", .body = @embedFile("zig-mit.txt") },
|
||||
.{ .name = "sqlite-public-domain.txt", .body = @embedFile("sqlite-public-domain.txt") },
|
||||
.{ .name = "mbedtls-apache-2.0.txt", .body = @embedFile("mbedtls-apache-2.0.txt") },
|
||||
.{ .name = "everest-apache-2.0.txt", .body = @embedFile("everest-apache-2.0.txt") },
|
||||
.{ .name = "p256-m-apache-2.0.txt", .body = @embedFile("p256-m-apache-2.0.txt") },
|
||||
.{ .name = "react-mit.txt", .body = @embedFile("react-mit.txt") },
|
||||
.{ .name = "tanstack-mit.txt", .body = @embedFile("tanstack-mit.txt") },
|
||||
.{ .name = "tanstack-store-mit.txt", .body = @embedFile("tanstack-store-mit.txt") },
|
||||
.{ .name = "tailwindcss-mit.txt", .body = @embedFile("tailwindcss-mit.txt") },
|
||||
.{ .name = "vite-mit.txt", .body = @embedFile("vite-mit.txt") },
|
||||
.{ .name = "rolldown-mit.txt", .body = @embedFile("rolldown-mit.txt") },
|
||||
.{ .name = "mozilla-ca-bundle-mpl-2.0.txt", .body = @embedFile("mozilla-ca-bundle-mpl-2.0.txt") },
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
Mbed TLS is provided under a dual Apache-2.0 OR GPL-2.0-or-later licence.
|
||||
nxdns takes Mbed TLS under the Apache-2.0 option. The full text of the
|
||||
Apache License, Version 2.0, as shipped in the Mbed TLS 3.6.7 LICENSE file,
|
||||
follows.
|
||||
|
||||
Copyright The Mbed TLS Contributors
|
||||
SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,407 @@
|
||||
The Mozilla CA certificate bundle, redistributed by the nxdns container image
|
||||
at /etc/ssl/certs/ca-certificates.crt (179,359 bytes). The final image is built
|
||||
FROM scratch and deploy/docker/Dockerfile copies that one file out of the
|
||||
pinned alpine:3.22 base, so the bundle is a byte of the image that no other
|
||||
entry in this file covers.
|
||||
|
||||
It comes from Alpine's ca-certificates-bundle package, version 20260611-r0,
|
||||
which apk records as:
|
||||
|
||||
L:MPL-2.0 AND MIT
|
||||
|
||||
(verified with `apk info ca-certificates-bundle` and the installed database of
|
||||
alpine:3.22). The bytes that ship are the concatenated PEM certificates
|
||||
generated from Mozilla NSS's certdata.txt, which carries this notice:
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
The MIT half of that expression covers the ca-certificates tooling —
|
||||
c_rehash.c, Copyright (c) 2013-2014 Timo Teras — which lives in the
|
||||
ca-certificates package, not in ca-certificates-bundle. The image does not
|
||||
install it: `apk info -L ca-certificates-bundle` lists only
|
||||
/etc/ssl/cert.pem, /etc/ssl/certs/ca-certificates.crt, /etc/ssl1.1/cert.pem
|
||||
and /etc/ssl1.1/certs, and only the second of those is copied into the image.
|
||||
|
||||
Nothing of this bundle is in the release tarballs or in the nxdns binary.
|
||||
nxdns verifies DoH and DoT upstreams against the trust store it finds on the
|
||||
host at runtime; it embeds no certificates of its own.
|
||||
|
||||
The full text of the Mozilla Public License, Version 2.0 follows.
|
||||
|
||||
============================================================================
|
||||
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1,193 @@
|
||||
musl as a whole is licensed under the following standard MIT license:
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Copyright © 2005-2020 Rich Felker, et al.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Authors/contributors include:
|
||||
|
||||
A. Wilcox
|
||||
Ada Worcester
|
||||
Alex Dowad
|
||||
Alex Suykov
|
||||
Alexander Monakov
|
||||
Andre McCurdy
|
||||
Andrew Kelley
|
||||
Anthony G. Basile
|
||||
Aric Belsito
|
||||
Arvid Picciani
|
||||
Bartosz Brachaczek
|
||||
Benjamin Peterson
|
||||
Bobby Bingham
|
||||
Boris Brezillon
|
||||
Brent Cook
|
||||
Chris Spiegel
|
||||
Clément Vasseur
|
||||
Daniel Micay
|
||||
Daniel Sabogal
|
||||
Daurnimator
|
||||
David Carlier
|
||||
David Edelsohn
|
||||
Denys Vlasenko
|
||||
Dmitry Ivanov
|
||||
Dmitry V. Levin
|
||||
Drew DeVault
|
||||
Emil Renner Berthing
|
||||
Fangrui Song
|
||||
Felix Fietkau
|
||||
Felix Janda
|
||||
Gianluca Anzolin
|
||||
Hauke Mehrtens
|
||||
He X
|
||||
Hiltjo Posthuma
|
||||
Isaac Dunham
|
||||
Jaydeep Patil
|
||||
Jens Gustedt
|
||||
Jeremy Huntwork
|
||||
Jo-Philipp Wich
|
||||
Joakim Sindholt
|
||||
John Spencer
|
||||
Julien Ramseier
|
||||
Justin Cormack
|
||||
Kaarle Ritvanen
|
||||
Khem Raj
|
||||
Kylie McClain
|
||||
Leah Neukirchen
|
||||
Luca Barbato
|
||||
Luka Perkov
|
||||
M Farkas-Dyck (Strake)
|
||||
Mahesh Bodapati
|
||||
Markus Wichmann
|
||||
Masanori Ogino
|
||||
Michael Clark
|
||||
Michael Forney
|
||||
Mikhail Kremnyov
|
||||
Natanael Copa
|
||||
Nicholas J. Kain
|
||||
orc
|
||||
Pascal Cuoq
|
||||
Patrick Oppenlander
|
||||
Petr Hosek
|
||||
Petr Skocik
|
||||
Pierre Carrier
|
||||
Reini Urban
|
||||
Rich Felker
|
||||
Richard Pennington
|
||||
Ryan Fairfax
|
||||
Samuel Holland
|
||||
Segev Finer
|
||||
Shiz
|
||||
sin
|
||||
Solar Designer
|
||||
Stefan Kristiansson
|
||||
Stefan O'Rear
|
||||
Szabolcs Nagy
|
||||
Timo Teräs
|
||||
Trutz Behn
|
||||
Valentin Ochs
|
||||
Will Dietz
|
||||
William Haddon
|
||||
William Pitcock
|
||||
|
||||
Portions of this software are derived from third-party works licensed
|
||||
under terms compatible with the above MIT license:
|
||||
|
||||
The TRE regular expression implementation (src/regex/reg* and
|
||||
src/regex/tre*) is Copyright © 2001-2008 Ville Laurikari and licensed
|
||||
under a 2-clause BSD license (license text in the source files). The
|
||||
included version has been heavily modified by Rich Felker in 2012, in
|
||||
the interests of size, simplicity, and namespace cleanliness.
|
||||
|
||||
Much of the math library code (src/math/* and src/complex/*) is
|
||||
Copyright © 1993,2004 Sun Microsystems or
|
||||
Copyright © 2003-2011 David Schultz or
|
||||
Copyright © 2003-2009 Steven G. Kargl or
|
||||
Copyright © 2003-2009 Bruce D. Evans or
|
||||
Copyright © 2008 Stephen L. Moshier or
|
||||
Copyright © 2017-2018 Arm Limited
|
||||
and labelled as such in comments in the individual source files. All
|
||||
have been licensed under extremely permissive terms.
|
||||
|
||||
The ARM memcpy code (src/string/arm/memcpy.S) is Copyright © 2008
|
||||
The Android Open Source Project and is licensed under a two-clause BSD
|
||||
license. It was taken from Bionic libc, used on Android.
|
||||
|
||||
The AArch64 memcpy and memset code (src/string/aarch64/*) are
|
||||
Copyright © 1999-2019, Arm Limited.
|
||||
|
||||
The implementation of DES for crypt (src/crypt/crypt_des.c) is
|
||||
Copyright © 1994 David Burren. It is licensed under a BSD license.
|
||||
|
||||
The implementation of blowfish crypt (src/crypt/crypt_blowfish.c) was
|
||||
originally written by Solar Designer and placed into the public
|
||||
domain. The code also comes with a fallback permissive license for use
|
||||
in jurisdictions that may not recognize the public domain.
|
||||
|
||||
The smoothsort implementation (src/stdlib/qsort.c) is Copyright © 2011
|
||||
Valentin Ochs and is licensed under an MIT-style license.
|
||||
|
||||
The x86_64 port was written by Nicholas J. Kain and is licensed under
|
||||
the standard MIT terms.
|
||||
|
||||
The mips and microblaze ports were originally written by Richard
|
||||
Pennington for use in the ellcc project. The original code was adapted
|
||||
by Rich Felker for build system and code conventions during upstream
|
||||
integration. It is licensed under the standard MIT terms.
|
||||
|
||||
The mips64 port was contributed by Imagination Technologies and is
|
||||
licensed under the standard MIT terms.
|
||||
|
||||
The powerpc port was also originally written by Richard Pennington,
|
||||
and later supplemented and integrated by John Spencer. It is licensed
|
||||
under the standard MIT terms.
|
||||
|
||||
All other files which have no copyright comments are original works
|
||||
produced specifically for use as part of this library, written either
|
||||
by Rich Felker, the main author of the library, or by one or more
|
||||
contibutors listed above. Details on authorship of individual files
|
||||
can be found in the git version control history of the project. The
|
||||
omission of copyright and license comments in each file is in the
|
||||
interest of source tree size.
|
||||
|
||||
In addition, permission is hereby granted for all public header files
|
||||
(include/* and arch/*/bits/*) and crt files intended to be linked into
|
||||
applications (crt/*, ldso/dlstart.c, and arch/*/crt_arch.h) to omit
|
||||
the copyright notice and permission notice otherwise required by the
|
||||
license, and to use these files without any requirement of
|
||||
attribution. These files include substantial contributions from:
|
||||
|
||||
Bobby Bingham
|
||||
John Spencer
|
||||
Nicholas J. Kain
|
||||
Rich Felker
|
||||
Richard Pennington
|
||||
Stefan Kristiansson
|
||||
Szabolcs Nagy
|
||||
|
||||
all of whom have explicitly granted such permission.
|
||||
|
||||
This file previously contained text expressing a belief that most of
|
||||
the files covered by the above exception were sufficiently trivial not
|
||||
to be subject to copyright, resulting in confusion over whether it
|
||||
negated the permissions granted in the license. In the spirit of
|
||||
permissive licensing, and of not having licensing issues being an
|
||||
obstacle to adoption, that text has been removed.
|
||||
@@ -0,0 +1,21 @@
|
||||
p256-m, vendored in the Mbed TLS 3.6.7 source tree under 3rdparty/p256-m/ and
|
||||
compiled into every nxdns binary. p256-m is provided under a dual Apache-2.0 OR
|
||||
GPL-2.0-or-later licence; nxdns takes it under the Apache-2.0 option.
|
||||
|
||||
Copyright The Mbed TLS Contributors
|
||||
Author: Manuel Pégourié-Gonnard
|
||||
SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
||||
these files except in compliance with the License. You may obtain a copy of the
|
||||
License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed
|
||||
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
||||
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
|
||||
The full text of the Apache License, Version 2.0 is reproduced above, in the
|
||||
Mbed TLS section of this file.
|
||||
@@ -0,0 +1,11 @@
|
||||
THIRD-PARTY-NOTICES
|
||||
|
||||
This file covers every released nxdns artifact: the two static musl tarballs
|
||||
and the container image. Most components below are compiled or embedded into
|
||||
the single executable and travel with every copy of it. One — the Mozilla CA
|
||||
certificate bundle — is not in the executable at all; the container image
|
||||
redistributes it as a file, and this file is what carries its licence. Each
|
||||
entry states which artifacts contain it.
|
||||
|
||||
This file is assembled by `zig build dist` from the reviewed inventory in
|
||||
licenses/, not from the dependency tree.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,34 @@
|
||||
Rolldown is the bundler Vite 8 runs, and it prepends its own CommonJS interop
|
||||
helpers — the `__commonJS`, `__toESM` and `__copyProps` closures built out of
|
||||
Object.create, Object.defineProperty and Object.getOwnPropertyNames — to every
|
||||
chunk that pulls in a CommonJS module. In this build they are the first 554
|
||||
bytes of web/dist/assets/classes-*.js, which is embedded in every nxdns binary.
|
||||
Like Vite's preload helper, they are a region of the chunk that no sourcemap
|
||||
segment maps back to a source file.
|
||||
|
||||
The helpers are emitted by the Rust core in @rolldown/binding-<platform>, whose
|
||||
version is locked to the `rolldown` package recorded in
|
||||
licenses/dependency-identity.txt; the whole project is under the one licence
|
||||
below.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present VoidZero Inc. & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,13 @@
|
||||
SQLite is in the public domain. The authors disclaim copyright; there is no
|
||||
licence obligation attached to redistributing it. It is listed here so that the
|
||||
inventory describes everything the shipped binaries contain.
|
||||
|
||||
The notice below is the one carried at the head of every file of the SQLite
|
||||
3.53.4 amalgamation:
|
||||
|
||||
The author disclaims copyright to this source code. In place of
|
||||
a legal notice, here is a blessing:
|
||||
|
||||
May you do good and not evil.
|
||||
May you find forgiveness for yourself and forgive others.
|
||||
May you share freely, never taking more than you give.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Tailwind Labs, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Tanner Linsley
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Tanner Linsley
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,36 @@
|
||||
Vite emits its own runtime source verbatim into the bundle that `vite build`
|
||||
writes to web/dist, and web/dist is embedded in every nxdns binary. Two pieces
|
||||
of Vite's code ship in web/dist/assets/index-*.js:
|
||||
|
||||
- the `__vite__mapDeps` table and the `__vitePreload` helper that dynamic
|
||||
imports are rewritten to call (vite/preload-helper), and
|
||||
- the modulepreload polyfill IIFE (vite/modulepreload-polyfill).
|
||||
|
||||
Both were read off the shipped chunk directly: they are the regions of
|
||||
index-*.js that no sourcemap segment maps back to a source file, because Vite
|
||||
injects them as virtual modules rather than as imports.
|
||||
|
||||
Vite is a devDependency, and none of it runs in the browser — but that code
|
||||
does, so the licence travels with the artifact.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019-present, VoidZero Inc. and Vite contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (Expat)
|
||||
|
||||
Copyright (c) Zig contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+266
-41
@@ -155,6 +155,12 @@ generated empty assets directory**, not against the placeholder. Ruling 4 makes
|
||||
the placeholder unbuildable, and re-admitting it through a back door for one
|
||||
size check would defeat the point.
|
||||
|
||||
**A second `zig build` invocation does not inherit the first one's `-D` options.**
|
||||
`zig build dist -Dversion-string=X` followed by a bare `zig build verify-dist`
|
||||
verifies a *differently configured* build. Every caller — the workflows, the
|
||||
documentation, and this spec's own acceptance list — passes the same
|
||||
`-Dversion-string`, `-Dgit-commit`, `-Dweb-dist` and `-Doptimize` to both.
|
||||
|
||||
### 6. Container image
|
||||
|
||||
`deploy/docker/Dockerfile`:
|
||||
@@ -227,8 +233,18 @@ milestone 13 has never run through CI.
|
||||
12. Sign the checksum file (ruling 8). Verify the signature locally before
|
||||
uploading it.
|
||||
13. Create the release as a **draft**; upload the assets.
|
||||
14. Publish the draft.
|
||||
15. **Move `:latest` last.**
|
||||
14. Move `:latest` and verify it resolves to the built digest. Re-check the
|
||||
monotonic-version invariant here: step 6 ran before the gates, and proves
|
||||
nothing about which of two in-flight tags finishes last.
|
||||
15. **Publish the draft last.** Publication is the one irreversible act, so it
|
||||
goes after everything that can still fail.
|
||||
|
||||
Amended after review. The original order published at 14 and moved `:latest` at
|
||||
15, which deadlocks: a failure while moving `:latest` leaves a published release,
|
||||
and ruling 9 makes a re-run refuse a published release. Nothing could repair it.
|
||||
The cost of the corrected order is a short window where `:latest` serves the new
|
||||
image before the release page is public. That is recoverable by a re-run; the
|
||||
deadlock was not.
|
||||
|
||||
Every action in `release.yml` is pinned to a full commit SHA.
|
||||
`actions/checkout@v4` and `mlugg/setup-zig@v2` are mutable tags on another
|
||||
@@ -238,9 +254,27 @@ no secrets.
|
||||
|
||||
### 8. Signing
|
||||
|
||||
Automated on the runner, with a dedicated GPG **signing subkey** of the author's
|
||||
existing commit-signing key. A leaked subkey is revoked on its own; the
|
||||
identity, the commit signature history and everyone's existing trust survive.
|
||||
**Two different keys are involved, and the original ruling conflated them.**
|
||||
|
||||
- The **tag-signing key** is the human's. `git tag -s` uses it, and step 3 checks
|
||||
it. What gets pinned in `release.yml` is the **primary certificate
|
||||
fingerprint**, which `git verify-tag --raw` emits as the **last** field of the
|
||||
`VALIDSIG` line. Field 3 is whichever key actually made the signature — the
|
||||
signing subkey once one exists. Pinning field 3 would mean that creating the
|
||||
release subkey below silently blocks every future release, with an error
|
||||
message that reads like a forged tag. Reproduced against a real keyring during
|
||||
review.
|
||||
- The **artifact-signing subkey** is the runner's, and it signs the checksum
|
||||
file. It is a dedicated GPG signing subkey of the author's existing key. A
|
||||
leaked subkey is revoked on its own; the identity, the commit signature history
|
||||
and everyone's existing trust survive.
|
||||
|
||||
Pinning the primary fingerprint means adding or rotating a signing subkey is a
|
||||
non-event for verification.
|
||||
|
||||
Every required secret is validated in the **guard job**, before the gates and
|
||||
before any registry push. Validating a fingerprint inside the signing step means
|
||||
a placeholder value burns an immutable version tag before it fails.
|
||||
|
||||
Implementation requirements: a temporary `GNUPGHOME`; assert the imported
|
||||
material contains no primary secret key; `--local-user <subkey-fingerprint>!`
|
||||
@@ -262,11 +296,26 @@ stronger claim than that.
|
||||
|
||||
### 9. Failure and recovery are specified, not improvised
|
||||
|
||||
- The draft is the unit of work. Nothing is visible until step 14.
|
||||
- The draft is the unit of work for the *release record*. The registry is not
|
||||
covered by it: the version tag becomes publicly pullable at step 10. Only the
|
||||
release page and its assets stay hidden until step 15.
|
||||
- The existence check happens **before** the push, not after. Gitea's container
|
||||
tags are mutable — immutability is a workflow invariant, not a registry
|
||||
guarantee — so a push-then-compare would already have overwritten the tag it
|
||||
claims to refuse. Read the existing digest with
|
||||
`HEAD /v2/mokhtar/nxdns/manifests/<version>`, sending an `Accept` header for
|
||||
the index media types and following the `401` bearer challenge with the PAT.
|
||||
Absent is `404`; present returns `Docker-Content-Digest`.
|
||||
- A re-run deletes an existing **draft** and repeats. It refuses to touch a
|
||||
**published** release.
|
||||
- The registry version tag is immutable. A re-run accepts an existing tag whose
|
||||
digest matches exactly what it just built, and refuses a differing one.
|
||||
- The registry version tag is immutable, and the workflow never writes it twice.
|
||||
A re-run that finds the tag present pushes nothing: it adopts the pushed
|
||||
digest and asserts the *contents* of that image against the artifacts it just
|
||||
built, which is the check that matters and the only one available. Comparing a
|
||||
rebuilt index digest was the first design and is not implementable — buildx
|
||||
cannot report an index digest without pushing, and cross-machine
|
||||
reproducibility is deferred (ruling 12), so the rebuilt digest is expected to
|
||||
differ even when nothing changed. See recorded deviation 10.
|
||||
- `:latest` moves last, so a failure between the image push and publication
|
||||
leaves the version tag pushed and `latest` untouched. That is recoverable by
|
||||
re-running.
|
||||
@@ -283,10 +332,15 @@ section, plus a generated appendix: `git log --oneline` since the previous tag
|
||||
inside a collapsed `<details>`, a compare link, the tarball hashes and the image
|
||||
digest.
|
||||
|
||||
First release: no previous tag exists, `git describe` fails, and the compare
|
||||
link is omitted. Later releases select the highest reachable **published** plain
|
||||
release, not merely the nearest git tag — an abandoned tag from ruling 9 must
|
||||
not become the comparison base.
|
||||
The nullable value is "the previous reachable **published release**", never "the
|
||||
previous git tag". An abandoned tag from ruling 9 must not become the comparison
|
||||
base, and `git describe` would pick exactly that.
|
||||
|
||||
With no published base — the first release, and equally the case where `v0.0.1`
|
||||
was abandoned and `v0.0.2` becomes the first published one — the log is
|
||||
`git log --oneline <tag>` and the compare link is omitted. It is **not** a range
|
||||
with an empty left side: `..v0.0.1` resolves against `HEAD` and produces a wrong
|
||||
or empty appendix rather than "all history".
|
||||
|
||||
The `v0.0.1` section is hand-written.
|
||||
|
||||
@@ -333,26 +387,43 @@ Recorded in PLAN so they are decisions rather than oversights:
|
||||
|
||||
None of these are code, and all of them block `v0.0.1`:
|
||||
|
||||
1. Create the GPG signing subkey, export it with `--export-secret-subkeys`,
|
||||
publish the public key to `keys.openpgp.org`, and store the armored subkey
|
||||
and its passphrase as secrets.
|
||||
2. Create the registry personal access token with package write scope.
|
||||
3. `app.ini`: add `.asc` and `.sig` to `[attachment] ALLOWED_TYPES`, and raise
|
||||
`MAX_FILES` from 5. Verified against the live instance: `max_size` is 100 MB
|
||||
(ample), `max_files` is 5 (this release has exactly five assets, no
|
||||
headroom), and `.asc` is absent, so the signature is rejected today.
|
||||
4. **Probe: can an extensionless asset be uploaded?** `ALLOWED_TYPES` is
|
||||
extension-based, and `SHA256SUMS` and `IMAGE-DIGEST` have none. If the API
|
||||
rejects them, they become `SHA256SUMS.txt`, `SHA256SUMS.txt.asc` and
|
||||
`IMAGE-DIGEST.txt`. Resolve by trying it against a scratch repository, not by
|
||||
assuming.
|
||||
The order matters, and the original list got parts of it wrong. Corrected:
|
||||
|
||||
1. Create the artifact-signing subkey, export it with `--export-secret-subkeys`,
|
||||
and publish the public key to `keys.openpgp.org`. Separately, identify which
|
||||
key actually signs your tags — after step 1 that is normally the new signing
|
||||
subkey, whose **primary** fingerprint is what gets pinned.
|
||||
2. Pin the primary fingerprint in `release.yml`, replacing the placeholder. The
|
||||
guard fails closed on the placeholder, so no tag can succeed before this.
|
||||
3. **Store** the secrets: `RELEASE_GPG_SUBKEY`, `RELEASE_GPG_PASSPHRASE`, and a
|
||||
registry personal access token with package write scope as `REGISTRY_TOKEN`.
|
||||
Creating the token is not storing it.
|
||||
4. `app.ini`: add `.asc` and `.sig` to `[attachment] ALLOWED_TYPES`, and raise
|
||||
`MAX_FILES` from 5. Restart Gitea and confirm the *effective* settings through
|
||||
`/api/v1/settings/attachment` before trusting them. Verified against the live
|
||||
instance on 2026-08-05: `max_size` is 100 MB (ample), `max_files` is 5 (this
|
||||
release has exactly five assets, no headroom), and `.asc` is absent, so the
|
||||
signature is rejected today. The implementation names the assets
|
||||
`SHA256SUMS.txt`, `SHA256SUMS.txt.asc` and `IMAGE-DIGEST.txt`, so `.txt` and
|
||||
`.asc` are the two extensions that must be allowed.
|
||||
5. **Probe: does the runner support `docker buildx` with the `docker-container`
|
||||
driver?** The current `docker` job only proves plain `docker build`.
|
||||
6. Push milestone 13 to `master`, set Gitea's default branch to `master`, then
|
||||
delete `origin/main` last.
|
||||
7. Merge the licence, changelog and `build.zig.zon` version commit, and let it
|
||||
pass on `master`, before creating the tag.
|
||||
8. Dry-run the whole release workflow with publication disabled.
|
||||
driver, and can it extract a foreign-platform image?** The old `docker` job
|
||||
only proved plain `docker build`, and the arm64 image-versus-tarball check
|
||||
needs more than that.
|
||||
6. Land the workflows on `master` **while `main` still exists**, and let the
|
||||
reusable-gates workflow run green once, so Gitea emits its actual
|
||||
status-check context names.
|
||||
7. Configure branch protection on `master` using **those observed names**. The
|
||||
reusable-workflow refactor changes them; keeping the old required checks can
|
||||
make merges impossible or leave the intended gates non-required.
|
||||
8. Change Gitea's default branch to `master`.
|
||||
9. Delete `origin/main` **last** — Gitea refuses to delete the default branch.
|
||||
10. Merge the licence, changelog and `build.zig.zon` version commit, and let
|
||||
`master` go green.
|
||||
11. Dry-run the release path. A tag-triggered workflow cannot be exercised
|
||||
without pushing *some* tag, so use a disposable tag or a scratch repository.
|
||||
**Never use `v0.0.1` as the dry run** and then expect to reuse it: tags are
|
||||
never moved (ruling 2), so a burned dry-run tag is spent.
|
||||
|
||||
## Sessions
|
||||
|
||||
@@ -392,31 +463,185 @@ Owns `docs/**`. Ruling 11, and milestone 13 ruling 3.
|
||||
`PLAN.md` (ruling 1), this spec, deletion of `origin/main`, the manual
|
||||
prerequisites in ruling 13, and the `v0.0.1` tag.
|
||||
|
||||
## Recorded (implementation)
|
||||
|
||||
Accepted deviations and corrections from integration. The five rulings amended
|
||||
above (7, 8, 9, 10, 13, plus the option-inheritance note in 5 and the scoping of
|
||||
the last acceptance line) were all wrong as first written; each amendment says
|
||||
what it replaced and why.
|
||||
|
||||
1. **`build.zig.zon` said `0.1.0`.** It traced to the first build-baseline
|
||||
commit — a scaffold default never bumped. Ruling 2 makes `verify-dist` assert
|
||||
it against the version under build, so the CI packaging gate could never have
|
||||
passed. Set to `0.0.1`, matching the first tag.
|
||||
2. **Assets carry a `.txt` extension**: `SHA256SUMS.txt`, `SHA256SUMS.txt.asc`,
|
||||
`IMAGE-DIGEST.txt`. This resolves ruling 13's extensionless-asset probe by
|
||||
construction — `.txt` is already in the live `ALLOWED_TYPES`, so only `.asc`
|
||||
still has to be added.
|
||||
3. **`INSTALL.md` was added** at the repository root. `dist` hard-requires it in
|
||||
the staged payload and `verify-dist` asserts its mode.
|
||||
4. **The notices preamble moved to `licenses/preamble.txt`.** The tarball and the
|
||||
image ship different sets, and the hardcoded preamble scoped itself to "a
|
||||
single static executable" — which the image is not. The image additionally
|
||||
redistributes Alpine's Mozilla CA bundle (`MPL-2.0 AND MIT`, 179,359 bytes);
|
||||
the tarball does not.
|
||||
5. **Vite joined Tailwind in the inventory.** Both are devDependencies whose
|
||||
generated output ships inside the binary. The original inventory applied that
|
||||
rule to one of them and stopped.
|
||||
6. **Node is pinned to `24.19.0`** in `gates.yml`, `release.yml` and
|
||||
`web/package.json` — the exact-patch pin ruling 12 asks for.
|
||||
7. **`gates.yml` is SHA-pinned too.** Ruling 7 pinned only `release.yml`, but
|
||||
`release.yml` calls `gates.yml`, and those jobs share the runner host and
|
||||
docker daemon with the job holding the signing subkey. `live-tls.yml` keeps
|
||||
moving tags; it references no secret.
|
||||
8. **Determinism measured better than claimed.** Two `dist` runs with separate
|
||||
cache directories and separate prefixes produced byte-identical tarballs — a
|
||||
genuine recompile, not a cache replay. The documentation still claims only
|
||||
same-directory determinism, because ruling 12's gate does not exist yet and an
|
||||
unguarded property decays. The stronger result is recorded here, not promised
|
||||
to operators.
|
||||
9. **Test count moved from 1461 to 1481**: twelve licence-drift tests, then eight
|
||||
more from the review pass below. Skip counts are unchanged.
|
||||
|
||||
The rest came out of an adversarial review of the finished implementation. Each
|
||||
was reproduced before it was fixed.
|
||||
|
||||
10. **The version tag was pushed before the immutability check ran.** The first
|
||||
implementation pushed `:$VERSION` and then compared the resulting digest
|
||||
with the pre-push one — a check that reports a violation it just caused. On
|
||||
a re-run that produced different bytes the tag was already overwritten and
|
||||
the original image lost. Replaced by the probe-then-adopt design ruling 9
|
||||
now describes: a real `HEAD /v2/…/manifests/<version>`, and an existing tag
|
||||
is adopted rather than rebuilt. Exercised against a fake registry covering
|
||||
`404`, `200`, the `401` bearer challenge, `500`, and a `200` with no
|
||||
`Docker-Content-Digest`.
|
||||
11. **The guard proved nothing about the signing key.** It checked that
|
||||
`RELEASE_GPG_SUBKEY` and `RELEASE_GPG_PASSPHRASE` were non-empty. A
|
||||
public-only export, an export missing the pinned subkey, and a placeholder
|
||||
passphrase all passed it and failed for the first time in the signing step —
|
||||
after the image push. The guard now imports the material, asserts the pinned
|
||||
subkey is present as a secret key, and signs a throwaway file with the
|
||||
passphrase.
|
||||
12. **`jq … | grep … || true` swallowed a malformed API response.** The `|| true`
|
||||
exists so grep's no-match is not fatal; it covers the whole pipeline, so a
|
||||
`200` carrying a JSON object instead of an array read as "no published
|
||||
releases" — the one wrong answer that moves `:latest` backwards. The payload
|
||||
is now type-checked before it is read.
|
||||
13. **The monotonic re-check was not the concurrency backstop it claimed to be.**
|
||||
Two concurrent releases are both drafts while they run, so neither appears in
|
||||
the other's published list and both pass. The workflow `concurrency:` group
|
||||
is the only thing that serialises them. A second check was added that does
|
||||
close it: `:latest`'s own version label is read from the registry
|
||||
immediately before the tag moves, so the invariant is checked against the
|
||||
state being mutated.
|
||||
14. **Publication could deadlock the tag.** A `PATCH` that Gitea committed but
|
||||
whose response was lost left the release public and the step failed, after
|
||||
which the guard refused every re-run. The step now re-reads the release and
|
||||
treats an already-published one as success.
|
||||
15. **The secret-scrub backstop killed the wrong gpg-agent.** `gpgconf --kill`
|
||||
acts on the agent of the `GNUPGHOME` it is pointed at, and the bare call
|
||||
killed the runner's default agent while every leaked temporary home's agent
|
||||
kept running with the key cached. It now kills each home in its own home,
|
||||
and the guard job has the same `if: always()` backstop the publish job had.
|
||||
16. **The Zig dependency guard could be silenced by pasting.** Unlike the npm
|
||||
half, nothing tied `build.zig.zon` back to the inventory, so a Mbed TLS bump
|
||||
plus the suggested identity paste left the notices claiming the old version.
|
||||
Each dependency now has to appear in the inventory at the version its URL
|
||||
names. The Zig toolchain version and the entries vendored inside Mbed TLS are
|
||||
checked the same way.
|
||||
17. **The container base image was not an input to any guard.** It is the source
|
||||
of the CA bundle the image redistributes. `deploy/docker/Dockerfile` is now
|
||||
embedded in the `licenses_files` module, its digest-pinned `FROM` is a
|
||||
recorded identity section, and the CA bundle entry must name the Alpine
|
||||
release that `FROM` pins.
|
||||
18. **A tree-shaken package that started shipping would have gone unnoticed.**
|
||||
`cookie-es`, `isbot`, `seroval` and `seroval-plugins` are in the lockfile
|
||||
closure and in no shipped byte. If application code imported one, no
|
||||
lockfile, version or dependency set would change — only the bundle. The
|
||||
frontend gate now recomputes the set of packages in `web/dist` from a
|
||||
`--sourcemap` build and diffs it against a recorded section, and the drift
|
||||
test requires every name in that section to be inventoried and refuses one
|
||||
that is still listed as not shipped.
|
||||
19. **The recorded npm licence token was parsed and discarded**, so a package
|
||||
that relicensed passed as long as its version had not moved. Shipped
|
||||
packages must now all carry the licence the inventory's texts assume;
|
||||
`npm_not_shipped` is exempt, and `isbot` is Unlicense.
|
||||
20. **The full-text licence checks were marker probes.** They prove the right
|
||||
document is present but survive most of it being deleted. The Apache-2.0 and
|
||||
MPL-2.0 texts are now pinned by SHA-256.
|
||||
21. **Zig's compiler-rt contains code ported from LLVM's.** Zig's `LICENSE` is
|
||||
bare MIT naming only "Zig contributors". Reviewed: the ports carry
|
||||
Apache-2.0 WITH LLVM-exception, and that exception waives Apache §4(a),
|
||||
§4(b) and §4(d) for portions embedded in object form — the only form nxdns
|
||||
ships — so no further notice is owed. Recorded in the Zig inventory note
|
||||
rather than left as an unexamined gap.
|
||||
22. **`npx` was replaced by the installed binary** in the new frontend gate. `npx`
|
||||
downloads a package it cannot find locally, so a wrong working directory
|
||||
would have turned a licence check into an unpinned fetch. Reproduced: it
|
||||
fetched `vite@8.2.0` over the pinned `8.1.5`.
|
||||
|
||||
### Not verified, and why
|
||||
|
||||
- **No workflow has ever executed.** `release.yml` and `gates.yml` were validated
|
||||
by YAML parse and `bash -n`, plus two steps lifted out and run directly: the
|
||||
registry probe against a fake registry (five response shapes) and the whole
|
||||
bundled-package check against the real `web/` build, proven able to fail.
|
||||
Everything else that talks to the registry or the Gitea API — `buildx build
|
||||
--push`, `imagetools`, draft creation, asset upload, publication, the
|
||||
adopt-an-existing-tag path — is unexercised.
|
||||
- **`RELEASE_SIGNING_FPR` is still the placeholder.** By design: the guard fails
|
||||
closed on it. It also means the release workflow cannot succeed as committed
|
||||
until manual prerequisite 2 is done.
|
||||
- **The aarch64 binary was never executed.** No `qemu-aarch64` on the build host,
|
||||
so `verify-dist`'s aarch64 version check legitimately skips and `test-aarch64`
|
||||
could not run. The binary is checked statically: ELF class, no `PT_INTERP`, no
|
||||
`DT_NEEDED`, size.
|
||||
- **The multi-architecture image build is unverified.** Only the native amd64
|
||||
image was built and run.
|
||||
- **`shellcheck` was not run** — not installed on the build host.
|
||||
- **The local Node is 24.14.1, not the pinned 24.19.0.** There is no `.npmrc`, so
|
||||
`engines` does not hard-fail, and the frontend gates ran under the older patch.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] `PLAN.md` §2.2 amended; the build-date line and the byte limits corrected.
|
||||
- [ ] `zig build dist -Dversion-string=0.0.1 -Dweb-dist=web/dist` produces two
|
||||
- [x] `PLAN.md` §2.2 amended; the build-date line and the byte limits corrected.
|
||||
§3.15 now describes the single gate set, and a new §20 records the
|
||||
publication model.
|
||||
- [x] `zig build dist -Dversion-string=0.0.1 -Dweb-dist=web/dist` produces two
|
||||
tarballs and a checksum file, and fails without `-Dweb-dist`.
|
||||
- [ ] `zig build verify-dist` passes, and was proven able to fail: an oversized
|
||||
- [x] `zig build verify-dist`, **given the same options as `dist`**, passes and
|
||||
was proven able to fail: an oversized
|
||||
binary, a dynamically linked binary, a mismatched `build.zig.zon` version
|
||||
and a wrong archive mode each produce a named failure.
|
||||
- [ ] Two runs of `zig build dist` on the same commit produce byte-identical
|
||||
and a wrong archive mode each produce a named failure. Proven by repacking
|
||||
tarballs with each defect: `binary-size`, `elf`, `zon-version` and
|
||||
`archive-mode` each fired and the run exited 1. The oversize proof used a
|
||||
genuinely oversized binary for `binary-size` and a lowered budget for
|
||||
`asset-free-size`.
|
||||
- [x] Two runs of `zig build dist` on the same commit produce byte-identical
|
||||
tarballs **in the same directory**. (Cross-directory reproducibility is
|
||||
ruling 12 and is not claimed here.)
|
||||
- [ ] The image builds for both platforms with no qemu, carries `/LICENSE` and
|
||||
`/THIRD-PARTY-NOTICES` and the OCI labels, and its binaries are
|
||||
byte-identical to the tarball binaries.
|
||||
byte-identical to the tarball binaries. Verified for the native amd64
|
||||
image only; the arm64 half needs a runner with buildx.
|
||||
- [ ] `gates.yml` runs from both `ci.yml` and `release.yml`; `ci.yml` triggers
|
||||
on `master`; `origin/main` is gone.
|
||||
- [ ] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
|
||||
on `master`; `origin/main` is gone. The first two are in the files; no
|
||||
workflow has run and `origin/main` still exists (manual prerequisite).
|
||||
- [x] `THIRD-PARTY-NOTICES` covers musl, the Zig runtime, SQLite, Mbed TLS with
|
||||
its Apache-2.0 selection line and full text, Everest, p256-m and the web
|
||||
runtime closure. The dependency drift guard was proven able to fail.
|
||||
runtime closure. The dependency drift guard was proven able to fail:
|
||||
removing an inventory entry, staling a dependency version, staling the Zig
|
||||
version, changing the base image digest, editing a pinned licence text and
|
||||
dropping a package from the recorded bundle each produce a named failure.
|
||||
- [ ] A dry run of `release.yml` completes with publication disabled.
|
||||
- [ ] `v0.0.1` is published: five assets, a verifying signature, and an image at
|
||||
`git.mial.net/mokhtar/nxdns:0.0.1` and `:latest`.
|
||||
- [ ] `docs/how-to/verify-a-release.md` was followed end to end against the
|
||||
published release, from a clean directory, on this host.
|
||||
- [ ] No `zig build cross` or source-only-distribution text remains anywhere.
|
||||
- [x] No `zig build cross` or source-only-distribution text remains on any
|
||||
**active** surface: `build.zig`, the workflows, `deploy/`, `README.md` and
|
||||
`docs/`. Historical milestone specs and `TECH_DEBT.md` keep their text —
|
||||
this spec contains the string itself, so "anywhere" was never satisfiable.
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,7 @@ comptime {
|
||||
_ = @import("server/doh_server.zig");
|
||||
_ = @import("web/handlers/certs.zig");
|
||||
_ = @import("docs_drift_test.zig");
|
||||
_ = @import("licenses_drift_test.zig");
|
||||
}
|
||||
|
||||
extern fn sqlite3_libversion() [*:0]const u8;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Release payload staging for `zig build dist` (milestone-14 rulings 3 and 4).
|
||||
//!
|
||||
//! Two modes, both writing only into paths the build system handed them:
|
||||
//!
|
||||
//! dist_stage stage --out <dir> --binary <path> --service <path>
|
||||
//! --sysusers <path> --license <path> --install-md <path>
|
||||
//! --licenses <dir>
|
||||
//!
|
||||
//! Fills `<dir>` with the six files of the tarball payload, each with the
|
||||
//! mode the release specifies. `THIRD-PARTY-NOTICES` is concatenated from
|
||||
//! the reviewed inventory at `<licenses>/inventory.zon`; it is never
|
||||
//! scraped from the dependency tree, because a generated notices file that
|
||||
//! nobody reads rots silently into a false statement.
|
||||
//!
|
||||
//! dist_stage sums --out <file> [--entry <name> <path>]...
|
||||
//!
|
||||
//! Writes `sha256sum`-format lines, one per `--entry`, in argument order.
|
||||
//! `<name>` is the name the asset carries in the release, not the cache
|
||||
//! path the bytes were read from.
|
||||
//!
|
||||
//! Modes are set with an explicit `setPermissions` after creation rather than
|
||||
//! through the creation mode, which the process umask would mask.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Io = std.Io;
|
||||
|
||||
const max_input_bytes = 256 << 20;
|
||||
|
||||
/// One reviewed component of the shipped artifacts. The build system owns this
|
||||
/// shape; `licenses/inventory.zon` is a tuple of these, in the order they
|
||||
/// should appear in `THIRD-PARTY-NOTICES`.
|
||||
/// Mirrors `Entry` in `src/licenses_drift_test.zig`, which type-checks the same
|
||||
/// file at compile time. The two must agree; a mismatch fails one of them.
|
||||
const Component = struct {
|
||||
/// The component as an operator would name it.
|
||||
component: []const u8,
|
||||
/// The version(s) actually shipped.
|
||||
version: []const u8,
|
||||
/// Why its licence applies to what we ship, and which option was taken
|
||||
/// when the component offers a choice.
|
||||
note: []const u8,
|
||||
/// Licence text, relative to `licenses/`.
|
||||
file: []const u8,
|
||||
};
|
||||
|
||||
const PayloadFile = struct {
|
||||
name: []const u8,
|
||||
mode: std.posix.mode_t,
|
||||
};
|
||||
|
||||
const payload_dir_mode: std.posix.mode_t = 0o755;
|
||||
|
||||
pub fn main(init: std.process.Init) !void {
|
||||
const arena = init.arena.allocator();
|
||||
const io = init.io;
|
||||
const args = try init.minimal.args.toSlice(arena);
|
||||
if (args.len < 2) std.process.fatal("usage: dist_stage <stage|sums> ...", .{});
|
||||
|
||||
if (std.mem.eql(u8, args[1], "stage")) return stage(arena, io, args[2..]);
|
||||
if (std.mem.eql(u8, args[1], "sums")) return sums(arena, io, args[2..]);
|
||||
std.process.fatal("unknown mode '{s}': expected `stage` or `sums`", .{args[1]});
|
||||
}
|
||||
|
||||
fn stage(arena: Allocator, io: Io, args: []const []const u8) !void {
|
||||
var out_path: ?[]const u8 = null;
|
||||
var binary: ?[]const u8 = null;
|
||||
var service: ?[]const u8 = null;
|
||||
var sysusers: ?[]const u8 = null;
|
||||
var license: ?[]const u8 = null;
|
||||
var install_md: ?[]const u8 = null;
|
||||
var licenses: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 2) {
|
||||
if (i + 1 >= args.len) std.process.fatal("'{s}' needs a value", .{args[i]});
|
||||
const value = args[i + 1];
|
||||
const flag = args[i];
|
||||
if (std.mem.eql(u8, flag, "--out")) {
|
||||
out_path = value;
|
||||
} else if (std.mem.eql(u8, flag, "--binary")) {
|
||||
binary = value;
|
||||
} else if (std.mem.eql(u8, flag, "--service")) {
|
||||
service = value;
|
||||
} else if (std.mem.eql(u8, flag, "--sysusers")) {
|
||||
sysusers = value;
|
||||
} else if (std.mem.eql(u8, flag, "--license")) {
|
||||
license = value;
|
||||
} else if (std.mem.eql(u8, flag, "--install-md")) {
|
||||
install_md = value;
|
||||
} else if (std.mem.eql(u8, flag, "--licenses")) {
|
||||
licenses = value;
|
||||
} else {
|
||||
std.process.fatal("unknown flag '{s}'", .{flag});
|
||||
}
|
||||
}
|
||||
|
||||
// `iterate` is what makes the handle usable with `setPermissions`
|
||||
// (Io/Dir.zig:1941), which the payload directory's own mode needs.
|
||||
var out = Io.Dir.cwd().openDir(io, required(out_path, "--out"), .{ .iterate = true }) catch |err| {
|
||||
std.process.fatal("cannot open --out directory '{s}': {t}", .{ out_path.?, err });
|
||||
};
|
||||
defer out.close(io);
|
||||
|
||||
const notices = try renderNotices(arena, io, required(licenses, "--licenses"));
|
||||
|
||||
try copyInto(arena, io, out, .{ .name = "nxdns", .mode = 0o755 }, required(binary, "--binary"));
|
||||
try copyInto(arena, io, out, .{ .name = "nxdns.service", .mode = 0o644 }, required(service, "--service"));
|
||||
try copyInto(arena, io, out, .{ .name = "nxdns.conf", .mode = 0o644 }, required(sysusers, "--sysusers"));
|
||||
try copyInto(arena, io, out, .{ .name = "LICENSE", .mode = 0o644 }, required(license, "--license"));
|
||||
try copyInto(arena, io, out, .{ .name = "INSTALL.md", .mode = 0o644 }, required(install_md, "--install-md"));
|
||||
try writeInto(io, out, .{ .name = "THIRD-PARTY-NOTICES", .mode = 0o644 }, notices);
|
||||
|
||||
// The payload directory is a tar member too, and the mode `--out` was
|
||||
// created with came from the build runner's umask.
|
||||
out.setPermissions(io, .fromMode(payload_dir_mode)) catch |err| {
|
||||
std.process.fatal("cannot set the mode of '{s}': {t}", .{ out_path.?, err });
|
||||
};
|
||||
}
|
||||
|
||||
fn sums(arena: Allocator, io: Io, args: []const []const u8) !void {
|
||||
var out_path: ?[]const u8 = null;
|
||||
var line_buffer: std.Io.Writer.Allocating = try .initCapacity(arena, 256);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) {
|
||||
if (std.mem.eql(u8, args[i], "--out")) {
|
||||
if (i + 1 >= args.len) std.process.fatal("'--out' needs a value", .{});
|
||||
out_path = args[i + 1];
|
||||
i += 2;
|
||||
} else if (std.mem.eql(u8, args[i], "--entry")) {
|
||||
if (i + 2 >= args.len) std.process.fatal("'--entry' needs a name and a path", .{});
|
||||
const name = args[i + 1];
|
||||
const path = args[i + 2];
|
||||
const bytes = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(max_input_bytes)) catch |err| {
|
||||
std.process.fatal("cannot read '{s}': {t}", .{ path, err });
|
||||
};
|
||||
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
|
||||
// The two-space separator is `sha256sum`'s text mode, which is what
|
||||
// `sha256sum -c SHA256SUMS` expects on the operator's machine.
|
||||
try line_buffer.writer.print("{s} {s}\n", .{ std.fmt.bytesToHex(digest, .lower), name });
|
||||
i += 3;
|
||||
} else {
|
||||
std.process.fatal("unknown flag '{s}'", .{args[i]});
|
||||
}
|
||||
}
|
||||
|
||||
try writeFileWithMode(io, Io.Dir.cwd(), required(out_path, "--out"), line_buffer.written(), 0o644);
|
||||
}
|
||||
|
||||
fn required(value: ?[]const u8, flag: []const u8) []const u8 {
|
||||
return value orelse std.process.fatal("'{s}' is required", .{flag});
|
||||
}
|
||||
|
||||
fn copyInto(arena: Allocator, io: Io, out: Io.Dir, file: PayloadFile, source: []const u8) !void {
|
||||
const bytes = Io.Dir.cwd().readFileAlloc(io, source, arena, .limited(max_input_bytes)) catch |err| {
|
||||
std.process.fatal("cannot read '{s}': {t}", .{ source, err });
|
||||
};
|
||||
try writeInto(io, out, file, bytes);
|
||||
}
|
||||
|
||||
fn writeInto(io: Io, out: Io.Dir, file: PayloadFile, bytes: []const u8) !void {
|
||||
try writeFileWithMode(io, out, file.name, bytes, file.mode);
|
||||
}
|
||||
|
||||
fn writeFileWithMode(
|
||||
io: Io,
|
||||
dir: Io.Dir,
|
||||
sub_path: []const u8,
|
||||
bytes: []const u8,
|
||||
mode: std.posix.mode_t,
|
||||
) !void {
|
||||
var handle = dir.createFile(io, sub_path, .{}) catch |err| {
|
||||
std.process.fatal("cannot create '{s}': {t}", .{ sub_path, err });
|
||||
};
|
||||
defer handle.close(io);
|
||||
handle.writeStreamingAll(io, bytes) catch |err| {
|
||||
std.process.fatal("cannot write '{s}': {t}", .{ sub_path, err });
|
||||
};
|
||||
// After the write, not through the creation mode: `open(2)` masks the
|
||||
// creation mode with the process umask, and the release modes are fixed.
|
||||
handle.setPermissions(io, .fromMode(mode)) catch |err| {
|
||||
std.process.fatal("cannot set the mode of '{s}': {t}", .{ sub_path, err });
|
||||
};
|
||||
}
|
||||
|
||||
/// `THIRD-PARTY-NOTICES`: the inventory's entries, in order, each with its
|
||||
/// selection note and the full text of its licence.
|
||||
fn renderNotices(arena: Allocator, io: Io, dir_path: []const u8) ![]const u8 {
|
||||
var dir = Io.Dir.cwd().openDir(io, dir_path, .{}) catch |err| {
|
||||
std.process.fatal("cannot open --licenses directory '{s}': {t}", .{ dir_path, err });
|
||||
};
|
||||
defer dir.close(io);
|
||||
|
||||
const source = dir.readFileAllocOptions(
|
||||
io,
|
||||
"inventory.zon",
|
||||
arena,
|
||||
.limited(max_input_bytes),
|
||||
.of(u8),
|
||||
0,
|
||||
) catch |err| {
|
||||
std.process.fatal("cannot read '{s}/inventory.zon': {t}", .{ dir_path, err });
|
||||
};
|
||||
|
||||
const components = try parseInventory(arena, source);
|
||||
if (components.len == 0) {
|
||||
std.process.fatal("'{s}/inventory.zon' lists no components", .{dir_path});
|
||||
}
|
||||
|
||||
// The preamble is a reviewed file rather than a literal here so that one
|
||||
// text states the artifact scope and the drift guards in
|
||||
// src/licenses_drift_test.zig can assert against it.
|
||||
const preamble = dir.readFileAlloc(io, "preamble.txt", arena, .limited(max_input_bytes)) catch |err| {
|
||||
std.process.fatal("cannot read '{s}/preamble.txt': {t}", .{ dir_path, err });
|
||||
};
|
||||
if (std.mem.trim(u8, preamble, " \t\r\n").len == 0) {
|
||||
std.process.fatal("'{s}/preamble.txt' is empty", .{dir_path});
|
||||
}
|
||||
|
||||
var sink: std.Io.Writer.Allocating = try .initCapacity(arena, 64 << 10);
|
||||
const w = &sink.writer;
|
||||
try w.writeAll(preamble);
|
||||
if (preamble[preamble.len - 1] != '\n') try w.writeByte('\n');
|
||||
try w.writeByte('\n');
|
||||
|
||||
for (components) |component| {
|
||||
const text = dir.readFileAlloc(io, component.file, arena, .limited(max_input_bytes)) catch |err| {
|
||||
std.process.fatal("cannot read '{s}/{s}': {t}", .{ dir_path, component.file, err });
|
||||
};
|
||||
try w.splatByteAll('=', 76);
|
||||
try w.print("\n{s}\n", .{component.component});
|
||||
try w.splatByteAll('=', 76);
|
||||
try w.print("\n\nVersion: {s}\n\n{s}\n\n{s}", .{ component.version, component.note, text });
|
||||
if (text.len != 0 and text[text.len - 1] != '\n') try w.writeByte('\n');
|
||||
try w.writeByte('\n');
|
||||
}
|
||||
|
||||
return sink.written();
|
||||
}
|
||||
|
||||
/// The inventory is a tuple of components, in the order they appear in the
|
||||
/// notices file.
|
||||
fn parseInventory(arena: Allocator, source: [:0]const u8) ![]const Component {
|
||||
var diagnostics: std.zon.parse.Diagnostics = .{};
|
||||
return std.zon.parse.fromSliceAlloc(
|
||||
[]const Component,
|
||||
arena,
|
||||
source,
|
||||
&diagnostics,
|
||||
.{ .free_on_error = false },
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.ParseZon => std.process.fatal("licenses/inventory.zon:\n{f}", .{&diagnostics}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
//! Release verification for `zig build verify-dist` (milestone-14 ruling 5).
|
||||
//!
|
||||
//! Every assert the CI shell used to make, in a program a developer can run on
|
||||
//! a laptop. Checks that only exist inside a workflow file are the brittleness
|
||||
//! this exists to remove.
|
||||
//!
|
||||
//! Usage (the build system supplies all of it):
|
||||
//!
|
||||
//! verify_dist --dist-dir <dir> --work-dir <dir> --version <v>
|
||||
//! --git-commit <c> --zon <build.zig.zon> --max-bytes <n>
|
||||
//! --asset-free-max-bytes <n> --host-arch <arch> [--qemu]
|
||||
//! [--archive <triple> <basename>]...
|
||||
//! [--asset-free <triple> <path>]...
|
||||
//!
|
||||
//! The archive checks read the tarball and then work on the *extracted* tree,
|
||||
//! never on the staging directory: a staging directory that is right proves
|
||||
//! nothing about the bytes an operator downloads. Layout, modes, symlinks and
|
||||
//! path traversal are read out of the tar headers, which is where an attacker
|
||||
//! or a broken build would put them.
|
||||
//!
|
||||
//! Every failure is reported before the process exits, so one run names every
|
||||
//! problem rather than the first.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Io = std.Io;
|
||||
const elf = std.elf;
|
||||
|
||||
const max_input_bytes = 256 << 20;
|
||||
|
||||
/// The tarball payload, exactly (milestone-14 ruling 4). Sorted by name, which
|
||||
/// is also the order `tar --sort=name` writes them in.
|
||||
const payload = [_]PayloadFile{
|
||||
.{ .name = "INSTALL.md", .mode = 0o644 },
|
||||
.{ .name = "LICENSE", .mode = 0o644 },
|
||||
.{ .name = "THIRD-PARTY-NOTICES", .mode = 0o644 },
|
||||
.{ .name = "nxdns", .mode = 0o755 },
|
||||
.{ .name = "nxdns.conf", .mode = 0o644 },
|
||||
.{ .name = "nxdns.service", .mode = 0o644 },
|
||||
};
|
||||
|
||||
const payload_dir_mode: u32 = 0o755;
|
||||
|
||||
const PayloadFile = struct {
|
||||
name: []const u8,
|
||||
mode: u32,
|
||||
};
|
||||
|
||||
const Archive = struct {
|
||||
triple: []const u8,
|
||||
basename: []const u8,
|
||||
};
|
||||
|
||||
const AssetFree = struct {
|
||||
triple: []const u8,
|
||||
path: []const u8,
|
||||
};
|
||||
|
||||
const Extracted = struct {
|
||||
/// Payload contents keyed by the order of `payload`.
|
||||
files: [payload.len][]const u8,
|
||||
|
||||
fn get(self: Extracted, name: []const u8) []const u8 {
|
||||
for (payload, self.files) |file, bytes| {
|
||||
if (std.mem.eql(u8, file.name, name)) return bytes;
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
};
|
||||
|
||||
/// Accumulates failures so a run reports all of them, and carries the writer so
|
||||
/// every line goes through one place.
|
||||
const Report = struct {
|
||||
out: *Io.Writer,
|
||||
failures: usize = 0,
|
||||
|
||||
fn pass(self: *Report, comptime check: []const u8, comptime fmt: []const u8, args: anytype) void {
|
||||
self.out.print("verify-dist: PASS " ++ check ++ ": " ++ fmt ++ "\n", args) catch {};
|
||||
}
|
||||
|
||||
fn fail(self: *Report, comptime check: []const u8, comptime fmt: []const u8, args: anytype) void {
|
||||
self.failures += 1;
|
||||
self.out.print("verify-dist: FAIL " ++ check ++ ": " ++ fmt ++ "\n", args) catch {};
|
||||
}
|
||||
|
||||
fn skip(self: *Report, comptime check: []const u8, comptime fmt: []const u8, args: anytype) void {
|
||||
self.out.print("verify-dist: SKIP " ++ check ++ ": " ++ fmt ++ "\n", args) catch {};
|
||||
}
|
||||
};
|
||||
|
||||
const Args = struct {
|
||||
dist_dir: []const u8 = "",
|
||||
work_dir: []const u8 = "",
|
||||
version: []const u8 = "",
|
||||
git_commit: []const u8 = "",
|
||||
zon: []const u8 = "",
|
||||
max_bytes: u64 = 0,
|
||||
asset_free_max_bytes: u64 = 0,
|
||||
host_arch: []const u8 = "",
|
||||
qemu: bool = false,
|
||||
archives: []const Archive = &.{},
|
||||
asset_free: []const AssetFree = &.{},
|
||||
};
|
||||
|
||||
pub fn main(init: std.process.Init) !u8 {
|
||||
const arena = init.arena.allocator();
|
||||
const io = init.io;
|
||||
const args = try parseArgs(arena, try init.minimal.args.toSlice(arena));
|
||||
|
||||
var out_buffer: [8192]u8 = undefined;
|
||||
var out = Io.File.stdout().writerStreaming(io, &out_buffer);
|
||||
var report: Report = .{ .out = &out.interface };
|
||||
|
||||
var dist = Io.Dir.cwd().openDir(io, args.dist_dir, .{}) catch |err| {
|
||||
std.process.fatal("cannot open --dist-dir '{s}': {t}", .{ args.dist_dir, err });
|
||||
};
|
||||
defer dist.close(io);
|
||||
|
||||
checkZonVersion(arena, io, &report, args);
|
||||
|
||||
for (args.archives) |archive| {
|
||||
const extracted = checkArchive(arena, io, &report, args, dist, archive) orelse continue;
|
||||
checkInstalledCopies(arena, io, &report, dist, archive, extracted);
|
||||
checkVersionOutput(arena, io, &report, args, archive);
|
||||
}
|
||||
|
||||
checkChecksums(arena, io, &report, args, dist);
|
||||
|
||||
for (args.asset_free) |entry| {
|
||||
const bytes = Io.Dir.cwd().readFileAlloc(io, entry.path, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("asset-free-build", "{s}: cannot read '{s}': {t}", .{ entry.triple, entry.path, err });
|
||||
continue;
|
||||
};
|
||||
checkElf(&report, "asset-free-elf", entry.triple, bytes);
|
||||
if (bytes.len > args.asset_free_max_bytes) {
|
||||
report.fail("asset-free-size", "{s}: {d} bytes exceeds the {d} byte budget", .{
|
||||
entry.triple, bytes.len, args.asset_free_max_bytes,
|
||||
});
|
||||
} else {
|
||||
report.pass("asset-free-size", "{s}: {d} of {d} bytes", .{
|
||||
entry.triple, bytes.len, args.asset_free_max_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (report.failures == 0) {
|
||||
report.out.print("verify-dist: {d} archives verified, no failures\n", .{args.archives.len}) catch {};
|
||||
} else {
|
||||
report.out.print("verify-dist: {d} failures\n", .{report.failures}) catch {};
|
||||
}
|
||||
out.interface.flush() catch {};
|
||||
return if (report.failures == 0) 0 else 1;
|
||||
}
|
||||
|
||||
fn parseArgs(arena: Allocator, argv: []const []const u8) !Args {
|
||||
var args: Args = .{};
|
||||
var archives: std.ArrayList(Archive) = .empty;
|
||||
var asset_free: std.ArrayList(AssetFree) = .empty;
|
||||
|
||||
var i: usize = 1;
|
||||
while (i < argv.len) {
|
||||
const flag = argv[i];
|
||||
if (std.mem.eql(u8, flag, "--qemu")) {
|
||||
args.qemu = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, flag, "--archive")) {
|
||||
requireValues(argv, i, flag, 2);
|
||||
try archives.append(arena, .{ .triple = argv[i + 1], .basename = argv[i + 2] });
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
if (std.mem.eql(u8, flag, "--asset-free")) {
|
||||
requireValues(argv, i, flag, 2);
|
||||
try asset_free.append(arena, .{ .triple = argv[i + 1], .path = argv[i + 2] });
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
requireValues(argv, i, flag, 1);
|
||||
const value = argv[i + 1];
|
||||
i += 2;
|
||||
if (std.mem.eql(u8, flag, "--dist-dir")) {
|
||||
args.dist_dir = value;
|
||||
} else if (std.mem.eql(u8, flag, "--work-dir")) {
|
||||
args.work_dir = value;
|
||||
} else if (std.mem.eql(u8, flag, "--version")) {
|
||||
args.version = value;
|
||||
} else if (std.mem.eql(u8, flag, "--git-commit")) {
|
||||
args.git_commit = value;
|
||||
} else if (std.mem.eql(u8, flag, "--zon")) {
|
||||
args.zon = value;
|
||||
} else if (std.mem.eql(u8, flag, "--host-arch")) {
|
||||
args.host_arch = value;
|
||||
} else if (std.mem.eql(u8, flag, "--max-bytes")) {
|
||||
args.max_bytes = std.fmt.parseInt(u64, value, 10) catch {
|
||||
std.process.fatal("--max-bytes '{s}' is not a number", .{value});
|
||||
};
|
||||
} else if (std.mem.eql(u8, flag, "--asset-free-max-bytes")) {
|
||||
args.asset_free_max_bytes = std.fmt.parseInt(u64, value, 10) catch {
|
||||
std.process.fatal("--asset-free-max-bytes '{s}' is not a number", .{value});
|
||||
};
|
||||
} else {
|
||||
std.process.fatal("unknown flag '{s}'", .{flag});
|
||||
}
|
||||
}
|
||||
|
||||
args.archives = archives.items;
|
||||
args.asset_free = asset_free.items;
|
||||
if (args.dist_dir.len == 0) std.process.fatal("--dist-dir is required", .{});
|
||||
if (args.work_dir.len == 0) std.process.fatal("--work-dir is required", .{});
|
||||
if (args.version.len == 0) std.process.fatal("--version is required", .{});
|
||||
return args;
|
||||
}
|
||||
|
||||
fn requireValues(argv: []const []const u8, index: usize, flag: []const u8, count: usize) void {
|
||||
if (index + count >= argv.len) std.process.fatal("'{s}' needs {d} value(s)", .{ flag, count });
|
||||
}
|
||||
|
||||
/// `build.zig.zon` holds the only other copy of the version. Zig requires the
|
||||
/// field and nothing reads it, so nothing but this catches a bump that was
|
||||
/// forgotten in the commit before the tag (milestone-14 ruling 2).
|
||||
fn checkZonVersion(arena: Allocator, io: Io, report: *Report, args: Args) void {
|
||||
const Manifest = struct { version: []const u8 };
|
||||
const source = Io.Dir.cwd().readFileAllocOptions(
|
||||
io,
|
||||
args.zon,
|
||||
arena,
|
||||
.limited(max_input_bytes),
|
||||
.of(u8),
|
||||
0,
|
||||
) catch |err| {
|
||||
report.fail("zon-version", "cannot read '{s}': {t}", .{ args.zon, err });
|
||||
return;
|
||||
};
|
||||
const manifest = std.zon.parse.fromSliceAlloc(Manifest, arena, source, null, .{
|
||||
.ignore_unknown_fields = true,
|
||||
.free_on_error = false,
|
||||
}) catch |err| {
|
||||
report.fail("zon-version", "cannot parse '{s}': {t}", .{ args.zon, err });
|
||||
return;
|
||||
};
|
||||
if (!std.mem.eql(u8, manifest.version, args.version)) {
|
||||
report.fail("zon-version", "build.zig.zon says '{s}', the build says '{s}'", .{
|
||||
manifest.version, args.version,
|
||||
});
|
||||
return;
|
||||
}
|
||||
report.pass("zon-version", "'{s}'", .{manifest.version});
|
||||
}
|
||||
|
||||
/// Reads the tarball, asserts its layout from the tar headers, extracts the
|
||||
/// payload under `--work-dir`, and returns the extracted contents.
|
||||
fn checkArchive(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
args: Args,
|
||||
dist: Io.Dir,
|
||||
archive: Archive,
|
||||
) ?Extracted {
|
||||
const gz = dist.readFileAlloc(io, archive.basename, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("archive-present", "{s}: cannot read '{s}': {t}", .{ archive.triple, archive.basename, err });
|
||||
return null;
|
||||
};
|
||||
|
||||
const expected_root = archive.basename[0 .. archive.basename.len - ".tar.gz".len];
|
||||
|
||||
var gz_reader: Io.Reader = .fixed(gz);
|
||||
const window = arena.alloc(u8, std.compress.flate.max_window_len) catch @panic("OOM");
|
||||
var decompress: std.compress.flate.Decompress = .init(&gz_reader, .gzip, window);
|
||||
|
||||
const name_buffer = arena.alloc(u8, std.fs.max_path_bytes) catch @panic("OOM");
|
||||
const link_buffer = arena.alloc(u8, std.fs.max_path_bytes) catch @panic("OOM");
|
||||
var it: std.tar.Iterator = .init(&decompress.reader, .{
|
||||
.file_name_buffer = name_buffer,
|
||||
.link_name_buffer = link_buffer,
|
||||
});
|
||||
|
||||
var found: [payload.len]?[]const u8 = @splat(null);
|
||||
var roots: usize = 0;
|
||||
var ok = true;
|
||||
|
||||
while (it.next() catch |err| {
|
||||
report.fail("archive-readable", "{s}: {s} is not a readable tar.gz: {t}", .{
|
||||
archive.triple, archive.basename, decompress.err orelse err,
|
||||
});
|
||||
return null;
|
||||
}) |entry| {
|
||||
const raw = std.mem.trimEnd(u8, entry.name, "/");
|
||||
|
||||
if (std.mem.startsWith(u8, raw, "/") or
|
||||
std.mem.indexOf(u8, raw, "..") != null or
|
||||
std.mem.indexOfScalar(u8, raw, '\\') != null)
|
||||
{
|
||||
report.fail("archive-traversal", "{s}: member '{s}' escapes the payload", .{ archive.triple, raw });
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind == .sym_link) {
|
||||
report.fail("archive-symlink", "{s}: member '{s}' is a symlink to '{s}'", .{
|
||||
archive.triple, raw, entry.link_name,
|
||||
});
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind == .directory) {
|
||||
roots += 1;
|
||||
if (!std.mem.eql(u8, raw, expected_root)) {
|
||||
report.fail("archive-root", "{s}: directory '{s}', expected '{s}'", .{
|
||||
archive.triple, raw, expected_root,
|
||||
});
|
||||
ok = false;
|
||||
} else if (entry.mode & 0o7777 != payload_dir_mode) {
|
||||
report.fail("archive-mode", "{s}: directory '{s}' has mode {o}, expected {o}", .{
|
||||
archive.triple, raw, entry.mode & 0o7777, payload_dir_mode,
|
||||
});
|
||||
ok = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = std.fmt.allocPrint(arena, "{s}/", .{expected_root}) catch @panic("OOM");
|
||||
if (!std.mem.startsWith(u8, raw, prefix)) {
|
||||
report.fail("archive-root", "{s}: member '{s}' is not under '{s}'", .{ archive.triple, raw, prefix });
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = raw[prefix.len..];
|
||||
const index = indexOfPayload(name) orelse {
|
||||
report.fail("archive-allowlist", "{s}: '{s}' is not part of the release payload", .{
|
||||
archive.triple, name,
|
||||
});
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
if (found[index] != null) {
|
||||
report.fail("archive-allowlist", "{s}: '{s}' appears more than once", .{ archive.triple, name });
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
if (entry.mode & 0o7777 != payload[index].mode) {
|
||||
report.fail("archive-mode", "{s}: '{s}' has mode {o}, expected {o}", .{
|
||||
archive.triple, name, entry.mode & 0o7777, payload[index].mode,
|
||||
});
|
||||
ok = false;
|
||||
}
|
||||
|
||||
var sink: Io.Writer.Allocating = .init(arena);
|
||||
it.streamRemaining(entry, &sink.writer) catch |err| {
|
||||
report.fail("archive-readable", "{s}: cannot read '{s}': {t}", .{ archive.triple, name, err });
|
||||
return null;
|
||||
};
|
||||
found[index] = sink.written();
|
||||
}
|
||||
|
||||
if (roots != 1) {
|
||||
report.fail("archive-root", "{s}: {d} top-level directories, expected exactly 1", .{
|
||||
archive.triple, roots,
|
||||
});
|
||||
ok = false;
|
||||
}
|
||||
|
||||
var contents: [payload.len][]const u8 = undefined;
|
||||
for (payload, found, &contents) |file, bytes, *slot| {
|
||||
slot.* = bytes orelse {
|
||||
report.fail("archive-allowlist", "{s}: '{s}' is missing from the payload", .{
|
||||
archive.triple, file.name,
|
||||
});
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
}
|
||||
if (!ok) return null;
|
||||
|
||||
report.pass("archive-layout", "{s}: {s} holds exactly the {d} release files", .{
|
||||
archive.triple, archive.basename, payload.len,
|
||||
});
|
||||
|
||||
const extracted: Extracted = .{ .files = contents };
|
||||
const binary = extracted.get("nxdns");
|
||||
|
||||
checkElf(report, "elf", archive.triple, binary);
|
||||
if (binary.len > args.max_bytes) {
|
||||
report.fail("binary-size", "{s}: {d} bytes exceeds the {d} byte budget", .{
|
||||
archive.triple, binary.len, args.max_bytes,
|
||||
});
|
||||
} else {
|
||||
report.pass("binary-size", "{s}: {d} of {d} bytes", .{ archive.triple, binary.len, args.max_bytes });
|
||||
}
|
||||
|
||||
writeExtracted(io, report, args, archive, extracted);
|
||||
return extracted;
|
||||
}
|
||||
|
||||
/// Materialises the payload under `--work-dir/<triple>/`, with the modes the
|
||||
/// archive declared, so `nxdns version` runs from an extracted tree and an
|
||||
/// operator can look at exactly what was checked.
|
||||
fn writeExtracted(io: Io, report: *Report, args: Args, archive: Archive, extracted: Extracted) void {
|
||||
var root = Io.Dir.cwd().createDirPathOpen(io, args.work_dir, .{}) catch |err| {
|
||||
report.fail("extract", "cannot create '{s}': {t}", .{ args.work_dir, err });
|
||||
return;
|
||||
};
|
||||
defer root.close(io);
|
||||
|
||||
var dir = root.createDirPathOpen(io, archive.triple, .{}) catch |err| {
|
||||
report.fail("extract", "{s}: cannot create the extraction directory: {t}", .{ archive.triple, err });
|
||||
return;
|
||||
};
|
||||
defer dir.close(io);
|
||||
|
||||
for (payload, extracted.files) |file, bytes| {
|
||||
var handle = dir.createFile(io, file.name, .{}) catch |err| {
|
||||
report.fail("extract", "{s}: cannot create '{s}': {t}", .{ archive.triple, file.name, err });
|
||||
return;
|
||||
};
|
||||
defer handle.close(io);
|
||||
handle.writeStreamingAll(io, bytes) catch |err| {
|
||||
report.fail("extract", "{s}: cannot write '{s}': {t}", .{ archive.triple, file.name, err });
|
||||
return;
|
||||
};
|
||||
handle.setPermissions(io, .fromMode(file.mode)) catch |err| {
|
||||
report.fail("extract", "{s}: cannot chmod '{s}': {t}", .{ archive.triple, file.name, err });
|
||||
return;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// `zig-out/dist/bin/<triple>/nxdns` and `zig-out/dist/stage/<name>/` must hold
|
||||
/// the same bytes and the same modes as the archive. They are the artifacts the
|
||||
/// container build and a local install reach for, and an install step that
|
||||
/// dropped the executable bit would otherwise only surface on an operator's
|
||||
/// machine.
|
||||
fn checkInstalledCopies(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
dist: Io.Dir,
|
||||
archive: Archive,
|
||||
extracted: Extracted,
|
||||
) void {
|
||||
const before = report.failures;
|
||||
const root = archive.basename[0 .. archive.basename.len - ".tar.gz".len];
|
||||
const bin_path = std.fmt.allocPrint(arena, "bin/{s}/nxdns", .{archive.triple}) catch @panic("OOM");
|
||||
checkInstalledFile(arena, io, report, dist, archive.triple, bin_path, extracted.get("nxdns"), 0o755);
|
||||
|
||||
for (payload, extracted.files) |file, bytes| {
|
||||
const path = std.fmt.allocPrint(arena, "stage/{s}/{s}", .{ root, file.name }) catch @panic("OOM");
|
||||
checkInstalledFile(arena, io, report, dist, archive.triple, path, bytes, file.mode);
|
||||
}
|
||||
|
||||
if (report.failures == before) {
|
||||
report.pass("installed-copy", "{s}: bin/ and stage/ match the archive, modes included", .{archive.triple});
|
||||
}
|
||||
}
|
||||
|
||||
fn checkInstalledFile(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
dist: Io.Dir,
|
||||
triple: []const u8,
|
||||
sub_path: []const u8,
|
||||
expected: []const u8,
|
||||
mode: u32,
|
||||
) void {
|
||||
const stat = dist.statFile(io, sub_path, .{ .follow_symlinks = false }) catch |err| {
|
||||
report.fail("installed-copy", "{s}: cannot stat '{s}': {t}", .{ triple, sub_path, err });
|
||||
return;
|
||||
};
|
||||
if (stat.kind != .file) {
|
||||
report.fail("installed-copy", "{s}: '{s}' is a {t}, not a regular file", .{ triple, sub_path, stat.kind });
|
||||
return;
|
||||
}
|
||||
if (stat.permissions.toMode() & 0o7777 != mode) {
|
||||
report.fail("installed-mode", "{s}: '{s}' has mode {o}, expected {o}", .{
|
||||
triple, sub_path, stat.permissions.toMode() & 0o7777, mode,
|
||||
});
|
||||
}
|
||||
const bytes = dist.readFileAlloc(io, sub_path, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("installed-copy", "{s}: cannot read '{s}': {t}", .{ triple, sub_path, err });
|
||||
return;
|
||||
};
|
||||
if (!std.mem.eql(u8, bytes, expected)) {
|
||||
report.fail("installed-copy", "{s}: '{s}' differs from the archived copy", .{ triple, sub_path });
|
||||
}
|
||||
}
|
||||
|
||||
/// A static binary has no interpreter and no shared-library dependencies.
|
||||
/// Matching the string `statically linked` out of `file(1)` is not that test:
|
||||
/// it reads a heuristic sentence, not the headers that decide it.
|
||||
fn checkElf(report: *Report, comptime check: []const u8, triple: []const u8, bytes: []const u8) void {
|
||||
const machine = expectedMachine(triple) orelse {
|
||||
report.fail(check, "{s}: no ELF machine is known for this triple", .{triple});
|
||||
return;
|
||||
};
|
||||
|
||||
if (bytes.len < @sizeOf(elf.Elf64.Ehdr) or !std.mem.eql(u8, bytes[0..4], elf.MAGIC)) {
|
||||
report.fail(check, "{s}: not an ELF file", .{triple});
|
||||
return;
|
||||
}
|
||||
if (bytes[elf.EI.CLASS] != elf.ELFCLASS64 or bytes[elf.EI.DATA] != elf.ELFDATA2LSB) {
|
||||
report.fail(check, "{s}: not a 64-bit little-endian ELF file", .{triple});
|
||||
return;
|
||||
}
|
||||
|
||||
var header: elf.Elf64.Ehdr = undefined;
|
||||
@memcpy(std.mem.asBytes(&header), bytes[0..@sizeOf(elf.Elf64.Ehdr)]);
|
||||
|
||||
if (header.machine != machine) {
|
||||
report.fail(check, "{s}: e_machine is {t}, expected {t}", .{ triple, header.machine, machine });
|
||||
return;
|
||||
}
|
||||
|
||||
if (header.phentsize != @sizeOf(elf.Elf64.Phdr)) {
|
||||
report.fail(check, "{s}: e_phentsize is {d}, expected {d}", .{
|
||||
triple, header.phentsize, @sizeOf(elf.Elf64.Phdr),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var interps: usize = 0;
|
||||
var needed: usize = 0;
|
||||
for (0..header.phnum) |index| {
|
||||
const offset = header.phoff + index * @sizeOf(elf.Elf64.Phdr);
|
||||
if (offset + @sizeOf(elf.Elf64.Phdr) > bytes.len) {
|
||||
report.fail(check, "{s}: program header {d} is past the end of the file", .{ triple, index });
|
||||
return;
|
||||
}
|
||||
var phdr: elf.Elf64.Phdr = undefined;
|
||||
@memcpy(std.mem.asBytes(&phdr), bytes[offset..][0..@sizeOf(elf.Elf64.Phdr)]);
|
||||
|
||||
switch (phdr.type) {
|
||||
.INTERP => interps += 1,
|
||||
.DYNAMIC => needed += countNeeded(report, check, triple, bytes, phdr) orelse return,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
if (interps != 0) {
|
||||
report.fail(check, "{s}: {d} PT_INTERP segment(s); the binary is dynamically linked", .{ triple, interps });
|
||||
return;
|
||||
}
|
||||
if (needed != 0) {
|
||||
report.fail(check, "{s}: {d} DT_NEEDED entr(ies); the binary depends on shared libraries", .{
|
||||
triple, needed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
report.pass(check, "{s}: {t}, no PT_INTERP, no DT_NEEDED", .{ triple, machine });
|
||||
}
|
||||
|
||||
fn countNeeded(
|
||||
report: *Report,
|
||||
comptime check: []const u8,
|
||||
triple: []const u8,
|
||||
bytes: []const u8,
|
||||
phdr: elf.Elf64.Phdr,
|
||||
) ?usize {
|
||||
if (phdr.offset + phdr.filesz > bytes.len) {
|
||||
report.fail(check, "{s}: the dynamic segment is past the end of the file", .{triple});
|
||||
return null;
|
||||
}
|
||||
var count: usize = 0;
|
||||
var offset: u64 = phdr.offset;
|
||||
while (offset + @sizeOf(elf.Elf64_Dyn) <= phdr.offset + phdr.filesz) : (offset += @sizeOf(elf.Elf64_Dyn)) {
|
||||
var dyn: elf.Elf64_Dyn = undefined;
|
||||
@memcpy(std.mem.asBytes(&dyn), bytes[@intCast(offset)..][0..@sizeOf(elf.Elf64_Dyn)]);
|
||||
if (dyn.d_tag == elf.DT_NEEDED) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
fn expectedMachine(triple: []const u8) ?elf.EM {
|
||||
const arch = triple[0 .. std.mem.indexOfScalar(u8, triple, '-') orelse triple.len];
|
||||
if (std.mem.eql(u8, arch, "x86_64")) return .X86_64;
|
||||
if (std.mem.eql(u8, arch, "aarch64")) return .AARCH64;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `nxdns version` must report the version and commit the build was given.
|
||||
/// Native architecture only: a foreign binary needs qemu, and a `verify-dist`
|
||||
/// that silently ran nothing would be worse than one that says it skipped.
|
||||
fn checkVersionOutput(
|
||||
arena: Allocator,
|
||||
io: Io,
|
||||
report: *Report,
|
||||
args: Args,
|
||||
archive: Archive,
|
||||
) void {
|
||||
const arch = archive.triple[0 .. std.mem.indexOfScalar(u8, archive.triple, '-') orelse archive.triple.len];
|
||||
const native = std.mem.eql(u8, arch, args.host_arch);
|
||||
|
||||
const path = std.fmt.allocPrint(arena, "{s}/{s}/nxdns", .{ args.work_dir, archive.triple }) catch @panic("OOM");
|
||||
const argv: []const []const u8 = if (native)
|
||||
&.{ path, "version" }
|
||||
else if (args.qemu)
|
||||
&.{ std.fmt.allocPrint(arena, "qemu-{s}", .{arch}) catch @panic("OOM"), path, "version" }
|
||||
else {
|
||||
report.skip("version-output", "{s}: a foreign binary needs qemu; pass -fqemu to run it", .{archive.triple});
|
||||
return;
|
||||
};
|
||||
|
||||
const result = std.process.run(arena, io, .{ .argv = argv }) catch |err| {
|
||||
report.fail("version-output", "{s}: cannot run {s}: {t}", .{ archive.triple, argv[0], err });
|
||||
return;
|
||||
};
|
||||
switch (result.term) {
|
||||
.exited => |code| if (code != 0) {
|
||||
report.fail("version-output", "{s}: `nxdns version` exited {d}: {s}", .{
|
||||
archive.triple, code, std.mem.trimEnd(u8, result.stderr, "\n"),
|
||||
});
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
report.fail("version-output", "{s}: `nxdns version` did not exit normally", .{archive.triple});
|
||||
return;
|
||||
},
|
||||
}
|
||||
|
||||
const expected = std.fmt.allocPrint(arena, "nxdns {s} ({s})", .{
|
||||
args.version, args.git_commit,
|
||||
}) catch @panic("OOM");
|
||||
var lines = std.mem.splitScalar(u8, result.stdout, '\n');
|
||||
const first = lines.next() orelse "";
|
||||
if (!std.mem.eql(u8, first, expected)) {
|
||||
report.fail("version-output", "{s}: `nxdns version` printed '{s}', expected '{s}'", .{
|
||||
archive.triple, first, expected,
|
||||
});
|
||||
return;
|
||||
}
|
||||
report.pass("version-output", "{s}: {s}", .{ archive.triple, first });
|
||||
}
|
||||
|
||||
/// `SHA256SUMS` covers the tarballs and nothing else. The container image
|
||||
/// digest does not exist until buildx has pushed, so the release job appends
|
||||
/// that line later; a line here would be a hash of something this step never
|
||||
/// saw.
|
||||
fn checkChecksums(arena: Allocator, io: Io, report: *Report, args: Args, dist: Io.Dir) void {
|
||||
const text = dist.readFileAlloc(io, "SHA256SUMS", arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("sha256sums", "cannot read SHA256SUMS: {t}", .{err});
|
||||
return;
|
||||
};
|
||||
|
||||
var seen: usize = 0;
|
||||
var ok = true;
|
||||
var lines = std.mem.splitScalar(u8, text, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
seen += 1;
|
||||
const separator = std.mem.indexOf(u8, line, " ") orelse {
|
||||
report.fail("sha256sums", "line '{s}' is not in sha256sum format", .{line});
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
const hex = line[0..separator];
|
||||
const name = line[separator + 2 ..];
|
||||
|
||||
const known = for (args.archives) |candidate| {
|
||||
if (std.mem.eql(u8, candidate.basename, name)) break true;
|
||||
} else false;
|
||||
if (!known) {
|
||||
report.fail("sha256sums", "'{s}' is not one of the release tarballs", .{name});
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = dist.readFileAlloc(io, name, arena, .limited(max_input_bytes)) catch |err| {
|
||||
report.fail("sha256sums", "cannot read '{s}': {t}", .{ name, err });
|
||||
ok = false;
|
||||
continue;
|
||||
};
|
||||
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
|
||||
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
|
||||
const actual = std.fmt.bytesToHex(digest, .lower);
|
||||
if (!std.mem.eql(u8, hex, &actual)) {
|
||||
report.fail("sha256sums", "'{s}' hashes to {s}, SHA256SUMS says {s}", .{ name, &actual, hex });
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (seen != args.archives.len) {
|
||||
report.fail("sha256sums", "{d} lines for {d} tarballs", .{ seen, args.archives.len });
|
||||
ok = false;
|
||||
}
|
||||
if (ok) report.pass("sha256sums", "{d} tarball hashes match", .{seen});
|
||||
}
|
||||
|
||||
fn indexOfPayload(name: []const u8) ?usize {
|
||||
for (payload, 0..) |file, index| {
|
||||
if (std.mem.eql(u8, file.name, name)) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
"node": "24.19.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user