From 31c86b620940d8369e98b8dfe4c56e4798549517 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Sun, 13 Sep 2026 11:14:35 +0200 Subject: [PATCH 1/3] Add reusable smoke test with restart and privilege checks tests/smoke.sh runs the image like the Supervisor does (host network, root-owned /data volume), checks resolution, DNSSEC and a restart, and can additionally assert an unprivileged start with --hardened. CI uses the functional mode for now; ShellCheck also covers the new script. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U8kiCNC4Fjf3vUb6HwLegP --- .github/workflows/ci.yml | 18 +++------ tests/smoke.sh | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) create mode 100755 tests/smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9031b3..a39e757 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,9 @@ jobs: with: path: "./unbound" - name: ShellCheck - run: shellcheck unbound/run.sh + run: | + shellcheck -s sh unbound/run.sh + shellcheck tests/smoke.sh build: runs-on: ubuntu-latest @@ -45,18 +47,8 @@ jobs: load: true push: false tags: unbound-test:ci - - name: Smoke test (resolution and DNSSEC) + - name: Smoke test (resolution, DNSSEC, restart) 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 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)}" From 9441fb7ae129bbfb890afd065ace0ca667666d1e Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Sun, 13 Sep 2026 11:18:36 +0200 Subject: [PATCH 2/3] Harden entry script (1.0.1) Run with set -euo pipefail and evaluate unbound-anchor explicitly: it returns 1 after creating or updating the anchor and can return 0 on write errors, so the existence of the anchor file now decides whether the resolver starts. ShellCheck runs pinned (v0.11.0) with the busybox dialect, which supports pipefail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U8kiCNC4Fjf3vUb6HwLegP --- .github/workflows/ci.yml | 3 +-- unbound/CHANGELOG.md | 5 +++++ unbound/config.yaml | 2 +- unbound/run.sh | 31 ++++++++++++++++++++++++------- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39e757..6781338 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,7 @@ jobs: path: "./unbound" - name: ShellCheck run: | - shellcheck -s sh unbound/run.sh - shellcheck tests/smoke.sh + 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 diff --git a/unbound/CHANGELOG.md b/unbound/CHANGELOG.md index 8304ac0..5e86636 100644 --- a/unbound/CHANGELOG.md +++ b/unbound/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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/config.yaml b/unbound/config.yaml index 8400afe..09a40fb 100644 --- a/unbound/config.yaml +++ b/unbound/config.yaml @@ -1,5 +1,5 @@ name: "Unbound" -version: "1.0.0" +version: "1.0.1" 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..6eef39c 100755 --- a/unbound/run.sh +++ b/unbound/run.sh @@ -1,13 +1,30 @@ #!/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 DATA_DIR=/data +readonly TRUST_ANCHOR="$DATA_DIR/root.key" +readonly PACKAGE_ANCHOR=/usr/share/dnssec-root/trusted-key.key + +mkdir -p "$DATA_DIR" +if [ ! -s "$TRUST_ANCHOR" ] && [ -s "$PACKAGE_ANCHOR" ]; then + cp "$PACKAGE_ANCHOR" "$TRUST_ANCHOR" +fi + +# 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 + +chown -R unbound:unbound "$DATA_DIR" unbound-checkconf /etc/unbound/unbound.conf exec unbound -d -p -c /etc/unbound/unbound.conf From e945ef110933b640f463482abdf8a75f64315f46 Mon Sep 17 00:00:00 2001 From: Tim Baumann Date: Sun, 13 Sep 2026 11:21:52 +0200 Subject: [PATCH 3/3] Run Unbound unprivileged from the start (1.1.0) The container now starts as user unbound. The Supervisor mounts /data root-owned on a fresh install, so the trust anchor moves into the image (/var/lib/unbound, owned by unbound) and is refreshed by unbound-anchor on start; Unbound no longer needs to setuid, chown or bind below 1024. CI enforces this with the smoke test in --hardened mode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U8kiCNC4Fjf3vUb6HwLegP --- .github/workflows/ci.yml | 4 ++-- unbound/CHANGELOG.md | 6 ++++++ unbound/DOCS.md | 6 +++++- unbound/Dockerfile | 8 ++++++-- unbound/config.yaml | 2 +- unbound/run.sh | 11 +---------- unbound/unbound.conf | 6 +++--- 7 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6781338..d483a75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,8 +46,8 @@ jobs: load: true push: false tags: unbound-test:ci - - name: Smoke test (resolution, DNSSEC, restart) + - 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; } - tests/smoke.sh unbound-test:ci + tests/smoke.sh unbound-test:ci --hardened diff --git a/unbound/CHANGELOG.md b/unbound/CHANGELOG.md index 5e86636..685ed08 100644 --- a/unbound/CHANGELOG.md +++ b/unbound/CHANGELOG.md @@ -1,5 +1,11 @@ # 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`. 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 09a40fb..5176964 100644 --- a/unbound/config.yaml +++ b/unbound/config.yaml @@ -1,5 +1,5 @@ name: "Unbound" -version: "1.0.1" +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 6eef39c..d7eb678 100755 --- a/unbound/run.sh +++ b/unbound/run.sh @@ -2,14 +2,7 @@ # shellcheck shell=busybox set -euo pipefail -readonly DATA_DIR=/data -readonly TRUST_ANCHOR="$DATA_DIR/root.key" -readonly PACKAGE_ANCHOR=/usr/share/dnssec-root/trusted-key.key - -mkdir -p "$DATA_DIR" -if [ ! -s "$TRUST_ANCHOR" ] && [ -s "$PACKAGE_ANCHOR" ]; then - cp "$PACKAGE_ANCHOR" "$TRUST_ANCHOR" -fi +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. @@ -24,7 +17,5 @@ if [ ! -s "$TRUST_ANCHOR" ]; then exit 1 fi -chown -R unbound:unbound "$DATA_DIR" - 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