diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7e2cb8..8b6e2ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 }} diff --git a/.github/workflows/package-updates.yml b/.github/workflows/package-updates.yml index 4758c4f..284ab25 100644 --- a/.github/workflows/package-updates.yml +++ b/.github/workflows/package-updates.yml @@ -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: | diff --git a/README.md b/README.md index 916b08e..d30fb6d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/unbound/CHANGELOG.md b/unbound/CHANGELOG.md index 685ed08..95d0903 100644 --- a/unbound/CHANGELOG.md +++ b/unbound/CHANGELOG.md @@ -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. diff --git a/unbound/DOCS.md b/unbound/DOCS.md index 5b9b2b0..fb5bc1d 100644 --- a/unbound/DOCS.md +++ b/unbound/DOCS.md @@ -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. diff --git a/unbound/config.yaml b/unbound/config.yaml index 5176964..5cb98c5 100644 --- a/unbound/config.yaml +++ b/unbound/config.yaml @@ -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" diff --git a/unbound/run.sh b/unbound/run.sh index d7eb678..92386a8 100755 --- a/unbound/run.sh +++ b/unbound/run.sh @@ -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 ;; diff --git a/unbound/unbound.conf b/unbound/unbound.conf index e200d46..e68cc17 100644 --- a/unbound/unbound.conf +++ b/unbound/unbound.conf @@ -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