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
16 changes: 10 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Home Assistant app linter
uses: frenck/action-addon-linter@v2.21.1
uses: frenck/action-addon-linter@b9cfd1bc62ba60d2c4fc96317b5f7ada5923f4a7 # v2.21.1
with:
path: "./unbound"
- name: ShellCheck
Expand All @@ -35,11 +37,13 @@ jobs:
platform: linux/arm64
smoke: false
steps:
- uses: actions/checkout@v7.0.1
- uses: docker/setup-qemu-action@v4.3.0
- uses: docker/setup-buildx-action@v4.3.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build ${{ matrix.arch }}
uses: docker/build-push-action@v7.3.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./unbound
platforms: ${{ matrix.platform }}
Expand Down
81 changes: 74 additions & 7 deletions .github/workflows/package-updates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,107 @@ on:
workflow_dispatch:

permissions:
contents: write
contents: read

concurrency:
group: package-updates
cancel-in-progress: false

jobs:
update:
# Detects package changes and verifies the release with a read-only token,
# so no third-party action ever runs next to a write token.
check:
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.pkg.outputs.changed }}
version: ${{ steps.pkg.outputs.version }}
lock_sha256: ${{ steps.lock.outputs.sha256 }}
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Build image with current Alpine packages
- name: Build image with current Alpine packages (amd64)
run: docker build --pull --no-cache -t unbound-pkg:ci unbound

- name: Compare package set
id: pkg
run: .github/scripts/package-update.sh unbound-pkg:ci

- name: Fingerprint package list
id: lock
if: steps.pkg.outputs.changed == 'true'
run: echo "sha256=$(sha256sum unbound/apk-packages.txt | cut -d ' ' -f 1)" >>"$GITHUB_OUTPUT"

# Pushes made with GITHUB_TOKEN do not trigger the CI workflow, so the release is verified here.
- name: Smoke test
- name: Smoke test (amd64)
if: steps.pkg.outputs.changed == 'true'
run: |
command -v dig >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq dnsutils; }
tests/smoke.sh unbound-pkg:ci --hardened

# Set up after the amd64 build: Buildx becomes the default builder and would not load plain docker builds.
- name: Set up QEMU
if: steps.pkg.outputs.changed == 'true'
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0

- name: Set up Buildx
if: steps.pkg.outputs.changed == 'true'
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0

- name: Build image (aarch64)
if: steps.pkg.outputs.changed == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./unbound
platforms: linux/arm64
pull: true
no-cache: true
load: true
push: false
tags: unbound-pkg:arm64

- name: Check configuration (aarch64)
if: steps.pkg.outputs.changed == 'true'
run: docker run --rm --platform linux/arm64 unbound-pkg:arm64 unbound-checkconf /etc/unbound/unbound.conf

- name: Home Assistant app linter
if: steps.pkg.outputs.changed == 'true'
uses: frenck/action-addon-linter@v2.21.1
uses: frenck/action-addon-linter@b9cfd1bc62ba60d2c4fc96317b5f7ada5923f4a7 # v2.21.1
with:
path: "./unbound"

# Holds the write token and therefore runs no third-party actions.
release:
needs: check
if: needs.check.outputs.changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Rebuilds instead of passing files between jobs; the next step ensures the verified package set is released.
- name: Rebuild and prepare release
id: pkg
run: |
docker build --pull --no-cache -t unbound-pkg:ci unbound
.github/scripts/package-update.sh unbound-pkg:ci

- name: Verify release matches the checked package set
env:
CHANGED: ${{ steps.pkg.outputs.changed }}
VERSION: ${{ steps.pkg.outputs.version }}
EXPECTED_VERSION: ${{ needs.check.outputs.version }}
EXPECTED_SHA256: ${{ needs.check.outputs.lock_sha256 }}
run: |
actual=$(sha256sum unbound/apk-packages.txt | cut -d ' ' -f 1)
if [ "$CHANGED" != true ] || [ "$VERSION" != "$EXPECTED_VERSION" ] || [ "$actual" != "$EXPECTED_SHA256" ]; then
echo "::error::Package set changed after the check job (version '$VERSION', sha256 $actual); run the workflow again."
exit 1
fi

- name: Commit release
if: steps.pkg.outputs.changed == 'true'
env:
VERSION: ${{ steps.pkg.outputs.version }}
run: |
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ Supports `amd64` and `aarch64`. See the [documentation](./unbound/DOCS.md).

- **CI** (`ci.yml`): app linter, ShellCheck, amd64/aarch64 builds and `tests/smoke.sh --hardened`
(resolution, DNSSEC, restart, unprivileged start) on every push to `main` and every pull request.
- **Package updates** (`package-updates.yml`, Mondays and on demand): rebuilds the image, compares the installed
Alpine packages with `unbound/apk-packages.txt` and, after the smoke test, commits a patch release with changelog.
- **Dependabot**: weekly pull requests for the Alpine base image and pinned GitHub Actions. A new Alpine branch
changes the package set, so the next package-update run releases it.
- **Package updates** (`package-updates.yml`, Mondays and on demand): rebuilds the image and compares the installed
Alpine packages with `unbound/apk-packages.txt`. On a change, a read-only `check` job runs the amd64 smoke test,
an aarch64 build with config check and the app linter; only then does the `release` job, which runs no
third-party actions, commit a patch release with changelog.
- **Dependabot**: weekly pull requests for the Alpine base image and the GitHub Actions, which are pinned to commit
SHAs. A new Alpine branch changes the package set, so the next package-update run releases it.
- GitHub pauses scheduled workflows after 60 days without repository activity; re-enable *Package updates*
in the Actions tab if that happens.

Expand Down
6 changes: 6 additions & 0 deletions unbound/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 1.1.1

- `unbound-anchor` uses IPv4 only, matching `do-ip6: no`, so a start without IPv6 does not wait for IPv6 timeouts.
- `unbound.conf` no longer restates Unbound defaults; the effective configuration is unchanged.
- Documentation: verification through Pi-hole explained, note on public names with private addresses.

## 1.1.0

- The container starts as the unprivileged user `unbound`; no root phase and no capabilities are needed.
Expand Down
22 changes: 16 additions & 6 deletions unbound/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,30 @@ The Pi-hole app must use the host network (e.g. [casperklein's Pi-hole app](http

**Do not combine with the DNSCrypt option of that Pi-hole app:** its dnscrypt-proxy also listens on `127.0.0.1:5335`.

## Private addresses in public DNS

Unbound drops answers from public DNS that point to private address ranges, which protects against DNS rebinding.
Public names that deliberately resolve to a private IP (for example `*.plex.direct`) therefore do not resolve.
The app has no option for exceptions; if you need such a name, add it as a local DNS record in Pi-hole.

## Verify

From a LAN client (replace the IP with your Home Assistant host):
From a LAN client, query Pi-hole on port 53 (replace `192.0.2.10` with the IP address of your Home Assistant host):

```bash
dig @192.168.178.56 dnssec-failed.org # expected: status SERVFAIL (DNSSEC validation works)
dig @192.168.178.56 example.com # expected: NOERROR with an answer
dig @192.0.2.10 dnssec-failed.org # expected: status SERVFAIL
dig @192.0.2.10 example.com # expected: NOERROR with an answer
```

These queries go through Pi-hole. The SERVFAIL shows that Unbound validates DNSSEC only while `127.0.0.1#5335`
is Pi-hole's only upstream server.

## Updates

The image is built on your device from `alpine:3.24`. A new app version triggers a rebuild, which also pulls
the current Unbound package of that Alpine branch.
The image is built on your device from the Alpine base image pinned in the `Dockerfile`. A new app version
triggers a rebuild, which also pulls the current Unbound package of that Alpine branch.

A weekly job rebuilds the image; when any Alpine package in it changed (for example `unbound` or `libssl3`),
it publishes a patch version after the smoke test passed, so Home Assistant offers the update.
it publishes a patch version after the amd64 smoke test and the aarch64 build passed, so Home Assistant offers
the update.
`apk-packages.txt` lists the package versions of the latest release.
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.1.0"
version: "1.1.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"
Expand Down
4 changes: 2 additions & 2 deletions unbound/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ set -euo pipefail
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.
# so the file check below is what decides. IPv4 only, matching do-ip6: no in unbound.conf.
rc=0
unbound-anchor -a "$TRUST_ANCHOR" || rc=$?
unbound-anchor -4 -a "$TRUST_ANCHOR" || rc=$?
case "$rc" in
0 | 1) ;;
*) echo "warning: unbound-anchor exited with $rc" >&2 ;;
Expand Down
10 changes: 0 additions & 10 deletions unbound/unbound.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,14 @@ server:
interface: 127.0.0.1
port: 5335
access-control: 127.0.0.0/8 allow
do-ip4: yes
do-ip6: no
prefer-ip6: no
do-udp: yes
do-tcp: yes

chroot: ""
username: ""
directory: "/var/lib/unbound"
pidfile: ""
auto-trust-anchor-file: "/var/lib/unbound/root.key"

harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
edns-buffer-size: 1232
prefetch: yes
num-threads: 1

private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
Expand Down