Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
85 changes: 85 additions & 0 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
@@ -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)}"
11 changes: 11 additions & 0 deletions unbound/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 5 additions & 1 deletion unbound/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions unbound/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion unbound/config.yaml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
22 changes: 15 additions & 7 deletions unbound/run.sh
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions unbound/unbound.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down