diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9031b3..d483a75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,8 @@ jobs: with: path: "./unbound" - name: ShellCheck - run: shellcheck unbound/run.sh + run: | + docker run --rm -v "$PWD:/mnt:ro" -w /mnt koalaman/shellcheck:v0.11.0 unbound/run.sh tests/smoke.sh build: runs-on: ubuntu-latest @@ -45,18 +46,8 @@ jobs: load: true push: false tags: unbound-test:ci - - name: Smoke test (resolution and DNSSEC) + - name: Smoke test (resolution, DNSSEC, restart, unprivileged start) if: matrix.smoke run: | command -v dig >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq dnsutils; } - docker run -d --name unbound --network host --tmpfs /data unbound-test:ci - for i in $(seq 1 30); do - dig @127.0.0.1 -p 5335 example.com +time=2 +tries=1 >/dev/null 2>&1 && break - sleep 1 - done - out=$(dig @127.0.0.1 -p 5335 example.com A +dnssec +time=5 +tries=2) - echo "$out" | grep -q 'status: NOERROR' || { echo "$out"; docker logs unbound; exit 1; } - echo "$out" | grep -qE 'flags: [a-z ]*\bad\b' || { echo "missing AD flag"; echo "$out"; docker logs unbound; exit 1; } - fail=$(dig @127.0.0.1 -p 5335 dnssec-failed.org A +time=5 +tries=2) - echo "$fail" | grep -q 'status: SERVFAIL' || { echo "DNSSEC validation not enforced"; echo "$fail"; docker logs unbound; exit 1; } - docker logs unbound + tests/smoke.sh unbound-test:ci --hardened diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..8023ecd --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Smoke test for the Unbound app image: resolution, DNSSEC, restart and optional privilege checks. +# Usage: tests/smoke.sh IMAGE [--hardened] +set -euo pipefail + +image=${1:?usage: tests/smoke.sh IMAGE [--hardened]} +mode=${2:-} +port=5335 +name="unbound-smoke-$$" +volume="unbound-smoke-data-$$" + +cleanup() { + docker rm -f "$name" >/dev/null 2>&1 || true + docker volume rm -f "$volume" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + docker logs "$name" >&2 2>&1 || true + exit 1 +} + +query() { + dig @127.0.0.1 -p "$port" "$@" +time=5 +tries=2 +} + +wait_ready() { + for _ in $(seq 1 30); do + if dig @127.0.0.1 -p "$port" example.com +time=2 +tries=1 >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + fail "resolver not ready on 127.0.0.1:$port" +} + +check_dns() { + local out + out=$(query example.com A +dnssec) + grep -q 'status: NOERROR' <<<"$out" || fail "example.com did not resolve" + grep -qE 'flags:[a-z ]* ad[ ;]' <<<"$out" || fail "AD flag missing, DNSSEC not validated" + out=$(query dnssec-failed.org A) + grep -q 'status: SERVFAIL' <<<"$out" || fail "dnssec-failed.org resolved, DNSSEC validation not enforced" +} + +check_unprivileged() { + local user uid + user=$(docker image inspect -f '{{.Config.User}}' "$image") + case "$user" in + "" | root | 0 | 0:*) fail "image starts as root (USER='$user')" ;; + esac + uid=$(docker exec "$name" awk '/^Uid:/ {print $2}' /proc/1/status) + [ "$uid" != "0" ] || fail "resolver process runs as uid 0" + echo "ok: image USER=$user, resolver uid=$uid" +} + +if ss -lnut | grep -q ":$port "; then + echo "FAIL: port $port is already in use on this host" >&2 + exit 1 +fi + +run_args=(--network host -v "$volume:/data") +if [ "$mode" = "--hardened" ]; then + run_args+=(--cap-drop ALL --security-opt no-new-privileges) +fi + +# A named volume is root-owned, like the /data folder the Supervisor mounts on a fresh install. +docker volume create "$volume" >/dev/null +docker run -d --name "$name" "${run_args[@]}" "$image" >/dev/null + +wait_ready +check_dns +echo "ok: resolution and DNSSEC" + +if [ "$mode" = "--hardened" ]; then + check_unprivileged +fi + +docker restart "$name" >/dev/null +wait_ready +check_dns +echo "ok: resolution and DNSSEC after restart" + +echo "PASS: $image ${mode:-(functional)}" diff --git a/unbound/CHANGELOG.md b/unbound/CHANGELOG.md index 8304ac0..685ed08 100644 --- a/unbound/CHANGELOG.md +++ b/unbound/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.1.0 + +- The container starts as the unprivileged user `unbound`; no root phase and no capabilities are needed. +- The DNSSEC trust anchor moved from `/data/root.key` into the image (`/var/lib/unbound/root.key`); `/data` is no longer used. A leftover `/data/root.key` from 1.0.x is ignored. +- CI enforces the unprivileged start (`--cap-drop ALL`, `no-new-privileges`). + +## 1.0.1 + +- Entry script runs with `set -euo pipefail`. +- `unbound-anchor` results are evaluated and the trust anchor file is verified instead of ignoring every failure. + ## 1.0.0 - Initial release: Unbound from Alpine 3.24 on `127.0.0.1:5335`, host network, DNSSEC trust anchor in `/data`. diff --git a/unbound/DOCS.md b/unbound/DOCS.md index fdc0400..7907acf 100644 --- a/unbound/DOCS.md +++ b/unbound/DOCS.md @@ -7,7 +7,11 @@ It resolves names directly from the root servers, so no third-party DNS provider - The app uses the host network and listens **only on `127.0.0.1:5335`** (UDP and TCP). It is not reachable from your LAN and does not conflict with DNS on port 53 or mDNS on port 5353. -- The DNSSEC trust anchor is stored in the app data folder (`/data/root.key`) and kept up to date automatically. +- The container runs as the unprivileged user `unbound` from the first command on and needs no capabilities. + Because of that it cannot listen on ports below 1024. +- The DNSSEC trust anchor ships with the image (`/var/lib/unbound/root.key`), is refreshed with `unbound-anchor` + on every start and follows key rollovers (RFC 5011) while running. The app data folder is not used; after an + update or reinstall the anchor is bootstrapped again from the image. - There are no options. Start the app and point your DNS server at it. ## Use with Pi-hole diff --git a/unbound/Dockerfile b/unbound/Dockerfile index 529922c..ec229e9 100644 --- a/unbound/Dockerfile +++ b/unbound/Dockerfile @@ -1,9 +1,13 @@ FROM alpine:3.24 -RUN apk add --no-cache unbound +RUN apk add --no-cache unbound \ + && install -d -o unbound -g unbound -m 0755 /var/lib/unbound \ + && install -o unbound -g unbound -m 0644 /usr/share/dnssec-root/trusted-key.key /var/lib/unbound/root.key COPY unbound.conf /etc/unbound/unbound.conf COPY run.sh /run.sh -RUN chmod a+x /run.sh +RUN chmod 0755 /run.sh + +USER unbound CMD ["/run.sh"] diff --git a/unbound/config.yaml b/unbound/config.yaml index 8400afe..5176964 100644 --- a/unbound/config.yaml +++ b/unbound/config.yaml @@ -1,5 +1,5 @@ name: "Unbound" -version: "1.0.0" +version: "1.1.0" slug: "unbound" description: "Recursive, DNSSEC-validating DNS resolver on 127.0.0.1:5335, e.g. as upstream for Pi-hole" url: "https://github.com/TimInTech/ha-addon-unbound" diff --git a/unbound/run.sh b/unbound/run.sh index 0118ed5..d7eb678 100755 --- a/unbound/run.sh +++ b/unbound/run.sh @@ -1,13 +1,21 @@ #!/bin/sh -set -e +# shellcheck shell=busybox +set -euo pipefail -mkdir -p /data -if [ ! -s /data/root.key ] && [ -s /usr/share/dnssec-root/trusted-key.key ]; then - cp /usr/share/dnssec-root/trusted-key.key /data/root.key +readonly TRUST_ANCHOR=/var/lib/unbound/root.key + +# unbound-anchor returns 1 after creating or updating the anchor and may return 0 even on write errors, +# so the file check below is what decides. +rc=0 +unbound-anchor -a "$TRUST_ANCHOR" || rc=$? +case "$rc" in + 0 | 1) ;; + *) echo "warning: unbound-anchor exited with $rc" >&2 ;; +esac +if [ ! -s "$TRUST_ANCHOR" ]; then + echo "error: no DNSSEC trust anchor at $TRUST_ANCHOR" >&2 + exit 1 fi -# unbound-anchor exits 1 when it updated the key, which is not an error -unbound-anchor -a /data/root.key || true -chown -R unbound:unbound /data unbound-checkconf /etc/unbound/unbound.conf exec unbound -d -p -c /etc/unbound/unbound.conf diff --git a/unbound/unbound.conf b/unbound/unbound.conf index 269fbe5..e200d46 100644 --- a/unbound/unbound.conf +++ b/unbound/unbound.conf @@ -14,10 +14,10 @@ server: do-tcp: yes chroot: "" - username: "unbound" - directory: "/data" + username: "" + directory: "/var/lib/unbound" pidfile: "" - auto-trust-anchor-file: "/data/root.key" + auto-trust-anchor-file: "/var/lib/unbound/root.key" harden-glue: yes harden-dnssec-stripped: yes