Ctrl K
ComunidadGuías…

ROADMAP.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# RootGuard roadmap to 1.0

Last reviewed: 2026-09-22

This is the canonical product and engineering roadmap. The public website
summarises it; implementation decisions and release readiness are tracked here.
Items are completed only when their acceptance criteria are verified.

**Newest first:** the "Post-1.0 / Future" section right below tracks all
current, active work and sits at the top of this file for exactly that
reason - within it, items are ordered newest first too. Everything from
"0.1.0-alpha.2" onward is the historical record of how RootGuard reached
1.0.0, kept in its original chronological (oldest-first) order below.

## Status and scope

**1.0.0 shipped 2026-09-14; 1.0.1/1.0.2/1.0.3 (patch releases) shipped
2026-09-17/2026-09-21/2026-09-21** - see `docs/release-history.md`/
`CHANGELOG.md` for what actually shipped in each release. Every milestone
through `1.0.0` below is complete and verified; current work now lives
entirely in the "Post-1.0 / Future" section immediately below.

The 1.0 scope was a **single-node Docker appliance**. Bare-metal/systemd and
multi-node management remain explicitly post-1.0.

Status symbols:

- `[x]` delivered and verified
- `[ ]` required for the milestone
- `Later` useful, but not a gate for that milestone

## Release rules

Every release candidate must satisfy all of these rules:

1. No browser request can choose container names, images, commands, mounts, or
   arbitrary configuration paths.
2. Every mutable DNS configuration path supports preview, validation,
   versioning, activation health checks, and rollback.
3. Every supported upgrade has a tested recovery path.
4. Documentation and the public website are updated in the same change as the
   feature they describe.
5. Images use immutable digests; version, commit, license, SBOM, and supported
   architectures are published.
6. Known limitations are explicit. A feature is never presented as delivered
   before its release gate passes.

---

## Post-1.0 / Future — extensions

Goal: let advanced operators add narrowly scoped integrations and guided
features without weakening RootGuard's validation, recovery, or appliance
security model. This work is explicitly deferred until after 1.0 and carries no
current release commitment ([#186](https://github.com/foxly-it/rootguard/issues/186)).

- [x] Security releases without waiting for a manual cut
      ([#772](https://github.com/foxly-it/rootguard/issues/772)) - daily,
      `security-release.yml` scans the latest published release's images;
      when they carry HIGH/CRITICAL findings that `main` has already fixed
      (main clean and green, last release >= 24 h old), it proposes the
      next patch release behind a required-reviewer approval, then runs
      the normal release pipeline with all its gates.
- [ ] Opt-in automatic installation of security releases - a WebGUI
      setting so an installation applies a new security release on its
      own (attestation check, maintenance window, existing rollback)
      instead of waiting for the operator's click. docker-proxy still
      needs its own update channel first
      ([#670](https://github.com/foxly-it/rootguard/issues/670)).
- [x] Test the documented upgrade path instead of bypassing the upgrade gate
      ([#725](https://github.com/foxly-it/rootguard/issues/725)) -
      `upgrade-test` was bypassed for 1.0.3, 1.0.4 and 1.0.5, each time for
      a docker-proxy bug in the previous release that self-update can't fix
      (#670). `scripts/ci/upgrade-prerequisites.txt` now lists the manual
      step the release notes give operators per previous version; the test
      performs it and then runs the unchanged self-update and checks.
      Polling timeouts name the cause (last updater status, docker-proxy
      rejections) instead of failing silently. Verified locally with the
      workflow's own step scripts: 1.0.4 -> 1.0.5 fails without the step
      (rename rejected) and succeeds with it.
- [x] Stop compiling arm64 images under QEMU
      ([#736](https://github.com/foxly-it/rootguard/issues/736)) - Unbound's
      jobs shared one gha cache scope, so whichever amd64-only job finished
      last hid the native `Test arm64` layers and build-push recompiled
      Unbound under emulation (14-21 min on every base image or pin
      change). Per-architecture scopes make that build a cache hit from the
      native leg. The Go and Node builder stages run on the build platform
      and cross-compile (`--platform=$BUILDPLATFORM`).
- [x] Bot PRs merge themselves once CI is green
      ([#727](https://github.com/foxly-it/rootguard/issues/727)) - the pin
      refresh workflow and the new `dependabot-auto-merge.yml` enable
      squash auto-merge; semver-major Dependabot updates stay manual
      (Docker is exempt from that check: only digest/patch updates are
      configured, and fetch-metadata misreads digest bumps as major). Uses
      `PIN_REFRESH_TOKEN` so the merge triggers main's CI; Dependabot runs
      need it as a Dependabot secret too.
- [x] Cache trivy's vulnerability DB across scan jobs
      ([#723](https://github.com/foxly-it/rootguard/issues/723)) - every
      scan job restores the newest DB (`.github/actions/trivy-db-cache`)
      instead of downloading ~128 MB from the registry; trivy still
      refreshes it once the DB's `NextUpdate` has passed, so results are
      never staler than with a fresh download. `nightly-security.yml` is
      the only writer and keeps a single entry, so the DB doesn't evict the
      buildx layer caches from the 10 GB Actions cache.
- [x] Dependabot for every shipped ecosystem
      ([#721](https://github.com/foxly-it/rootguard/issues/721)) - all five
      Go modules, the webapp frontend's npm dependencies and the base image
      digests of all seven component Dockerfiles now get weekly Dependabot
      PRs (minor/patch/digest grouped per ecosystem, majors separate) with
      a 3-day cooldown on version updates. Minor/major base image series
      changes stay deliberate. Unbound and Blockpage use literal `FROM`
      lines instead of `ARG` defaults, which Dependabot cannot resolve; the
      apt/apk pin checks read the final `FROM` line.
- [x] Decouple upstream vulnerability data from the PR gate
      ([#719](https://github.com/foxly-it/rootguard/issues/719)) - nearly
      every red PR between 2026-09-21 and 2026-10-16 was red only because
      upstream rated a new CVE. `scripts/ci/pr-gate.sh` now makes image
      scans, `npm audit`, govulncheck and `trivy fs` gate a PR only when
      it touches what they cover (otherwise: warning annotation); push,
      schedule and release runs always gate. The new nightly
      `nightly-security.yml` runs the full set on `main` and keeps one
      tracking issue open while anything fails. No job is skipped, so the
      required checks are unchanged.
- [x] Local image scan that matches CI exactly
      ([#714](https://github.com/foxly-it/rootguard/issues/714)) -
      `scripts/ci/scan-images.sh [--preview] [component...]` builds the
      images and scans them with the CI-pinned trivy (its digest-pinned
      container image, so macOS and Linux run the same binary), the same
      gate and ignore file, plus `trivy fs`. `--preview` lists UNKNOWN
      findings, i.e. what is likely to turn CI red next.
- [x] Extend the daily pin-freshness bot to the Alpine `apk` pins in
      Core, Updater and Blockpage
      ([#712](https://github.com/foxly-it/rootguard/issues/712)) - those
      broke CI by hand four times. `scripts/check-alpine-pins.sh` checks
      each pin against `apk policy` inside the exact pinned base, and
      `debian-pin-freshness.yml` (now "Package pin freshness") puts Debian
      and Alpine fixes into one PR.
- [x] Warn ahead of expiring `.trivyignore.yaml` suppressions
      ([#710](https://github.com/foxly-it/rootguard/issues/710)) - trivy
      silently stops honoring an entry once `expired_at` passes, and 61 of
      62 entries shared one date. `scripts/ci/check-trivyignore-expiry.sh`
      plus the daily `trivyignore-expiry.yml` keep one tracking issue
      current from 14 days ahead and close it once nothing is due.
- [x] Bump Core/Updater's `docker:29-cli` base
      ([#703](https://github.com/foxly-it/rootguard/issues/703)) to the
      2026-10-09 build: Docker CLI 29.9.0 (Go 1.26.9), compose 5.6.0,
      Alpine 3.24.2. The docker binary now has no scanner findings, compose
      none of its older ones; the libexpat/pcre2 stopgap pins and 5
      compose-only suppressions are gone, the rest narrowed to cosign.
      Still open upstream: cosign v3.1.3 (Go 1.26.4) and compose 5.6.0
      (Go 1.26.8) carry Go 1.26.9 fixes trivy hasn't rated yet.
- [x] Run CI on `debian-pin-freshness.yml`'s bot PRs
      ([#697](https://github.com/foxly-it/rootguard/issues/697)) - the
      push/PR step now uses an optional, dedicated `PIN_REFRESH_TOKEN`
      instead of `GITHUB_TOKEN`, whose events never trigger workflows (#689
      sat in `action_required` for days while main's Unbound CI was red).
      Verified end to end on 2026-10-09 with the real secret: a dispatch on
      a throwaway branch with a deliberately stale pin fixed it, opened #705
      as the token owner, and every `pull_request` workflow started on its
      own.
- [x] Move WebApp's runtime stage from `distroless/base-debian12` to
      `distroless/static-debian12`
      ([#695](https://github.com/foxly-it/rootguard/issues/695)) - the
      binary is `CGO_ENABLED=0` and statically linked, so base's glibc,
      libssl3 and OpenSSL were never loaded, only scanner surface
      (CVE-2026-84782 needed a suppression for exactly that, #694). The
      image now carries five OS packages (base-files, ca-certificates,
      media-types, netbase, tzdata), and that suppression is gone.
- [x] Add `rootguard-docker-proxy` to `release-alpha.yml`'s own publish
      matrix ([#666](https://github.com/foxly-it/rootguard/issues/666)) -
      found cutting `1.0.2`: this component was never added when it
      joined the deployed stack, so nothing ever refreshed
      `compose.release.yaml`'s pin at release time the way every other
      component's pin is refreshed. Missed twice as a result (`1.0.2`,
      then `1.0.4` - see `docs/release-history.md`'s entries for both
      emergency pin bumps this forced, and a hand-written warning
      comment that itself failed to prevent the second occurrence).
      Fixed properly rather than patched again: `rootguard-docker-proxy`
      now goes through the exact same `publish`/`image-scan`/
      `smoke-test`/`update-alpha-pins` treatment every other component
      gets - real digest attestation, a genuine `smoke-test` run against
      its own new candidate (previously silently skipped, since nothing
      overrode the default pin for that job), and an automatic pin
      refresh to a real semver tag on every release from here on, not a
      hand-maintained `sha-<commit>` reference. Distinct from the
      self-update channel item below - this is about fresh-install pin
      freshness, not updating an already-deployed instance.
- [x] Rootless-Docker-daemon compatibility verification and
      documentation - the second, host-level phase planned after this same
      section's own docker-proxy wiring item, using the existing
      backup/restore feature as the migration path for existing
      installations rather than a new tool. The finding that would have
      ruled this out entirely - whether rootless Docker's networking
      silently drops the real client IP on DNS queries, breaking
      AdGuard's per-client filtering - is confirmed
      and resolved: the default configuration loses it (reconfirmed on two
      different RootlessKit network drivers), but explicitly configuring
      the `pasta` network/port driver preserves it. A full
      `compose.release.yaml` deployment was then exercised end to end under
      rootless Docker: `install.sh` auto-detects the rootless daemon and
      wires `docker-proxy`'s socket path with no operator action, the
      guided setup deployed AdGuard/Unbound successfully, and AdGuard's own
      query log confirmed the real client IP on a fully bootstrapped
      instance. The backup/restore migration path was verified too - an
      encrypted backup from a real rootful installation restored cleanly
      onto a fresh rootless one, settings intact. Two more real,
      independent requirements surfaced only by this end-to-end run (not
      knowable from documentation alone): the guided setup's DNS bind
      address must be `0.0.0.0` rather than a specific host IP under
      `pasta`, and privileged port binding for `pasta` needs
      `net.ipv4.ip_unprivileged_port_start` lowered - the `setcap`-based
      method some general rootless-Docker guides recommend does not work
      for `pasta`'s automatic port forwarding. Full detail, including the
      two `rootguard-docker-proxy` allowlist gaps this same exercise found
      and fixed (an Unbound-exec gap and a missing `GET /system/df`), in
      `docs/rootless-docker.md`.
- [x] Close the Docker-socket host-takeover risk named in
      `docs/threat-model.md`'s actor 1 ("Docker socket holders"): a bug in
      Core or the Updater that let an attacker issue their own Docker API
      calls used to lead directly to host compromise, no second line of
      defense. `rootguard-docker-proxy`, a purpose-built, request-body-
      filtering Docker Engine API proxy, is now wired into
      `compose.release.yaml` - Core and the Updater no longer mount
      `/var/run/docker.sock` themselves, only the proxy does; they reach
      it over `DOCKER_HOST=tcp://docker-proxy:2375`, guarded by a
      `ROOTGUARD_DOCKER_PROXY_URL` startup preflight check following the
      same pattern `rootguard-attestation-proxy` already established (see
      this file's `0.9` section). Backward-compatible
      by construction: an installation that only ever updated via the
      WebGUI keeps its old compose topology (direct socket access) until
      a fresh install or a manual `compose.release.yaml` refresh, since
      self-update can never deliver a compose-topology change (same
      residual-risk shape already true for every other topology change
      this project has shipped, not a new gap).
- [ ] Drop `USER root` from `rootguard-core`/`rootguard-updater`'s own
      Dockerfiles now that neither holds the real Docker socket anymore -
      deliberately deferred from this same section's docker-proxy wiring
      item, since existing installations have volumes (`rootguard-data`,
      `unbound-config`, `adguard-auth`, `rootguard-sessions`) currently
      owned by root; switching to a non-root UID needs its own
      volume-ownership migration design first, not just a Dockerfile
      change.
- [ ] Give `rootguard-docker-proxy` its own self-update channel, matching
      `rootguard-attestation-proxy`'s (added to Core's own internal update
      manager's target list, `rootguard-core/internal/updater/manager.go`
      - a different mechanism from the standalone `rootguard-updater`
      binary, which only ever swaps Core/WebApp themselves). Deliberately
      deferred from this same section's docker-proxy wiring item to keep
      that change's blast radius smaller. **No longer just a parity gap**:
      confirmed live cutting `1.0.3`
      ([#670](https://github.com/foxly-it/rootguard/issues/670)) that any
      installation with docker-proxy wired in as sole socket holder (every
      real `1.0.2` install) cannot self-update Core/WebApp at all until
      docker-proxy itself is refreshed - and, structurally, docker-proxy
      can never self-update through itself either way (its own real
      Docker-socket bind mount would need to pass its own request
      validation to recreate itself). Needs a fundamentally different
      mechanism than every other component's self-update, not just a new
      target-list entry - e.g. Core recreating it directly against the
      real socket during a narrow, explicitly-privileged maintenance
      window, bypassing docker-proxy's own request path for that one
      operation only. Manual remediation for an already-affected install
      documented in `docs/release-history.md`'s `1.0.3` entry.
- [ ] Give self-update a real compose-topology migration path. Today it only
      ever swaps container *images* in place against whatever
      `compose.release.yaml` already exists on disk (documented in
      `docs/release-process.md`, "Self-update can never deliver a
      compose-topology change") - a release that adds a new service, network,
      mount, or env var (like `rootguard-attestation-proxy`/`egress` in
      `1.0.0-rc.2`) can never reach an installation that updated via the
      WebGUI alone; only a fresh install or a manual compose refresh can
      cross it. `RequireAttestation`/`CheckAttestationProxyReachable` already
      turn this into a clear, actionable error instead of a hang or a generic
      cosign network failure, but that's a diagnosis, not a fix. Flagged
      again in an external code review (2026-09-08); three candidate
      directions, unevaluated so far: a signed compose migration mechanism,
      a bootstrapper process outside the stack itself, or a clearly guided,
      manual pre-update compose refresh step surfaced in the WebGUI before
      the image swap runs.
- [ ] Define a versioned extension API, compatibility contract, capability
      model, and explicit permission boundaries. A concrete, reviewed design
      draft exists for all four bullets in this section -
      `docs/extension-api-design.md` - written against a reference guided
      access-rules extension; not implemented, no timeline attached.
- [ ] Provide constrained integration, configuration, and UI extension points;
      RootGuard must retain ownership of preview, validation, activation,
      history, rollback, audit, backup, and restore.
- [ ] Define signing or explicit trust handling plus safe install, disable,
      upgrade, failure-isolation, and removal semantics. Unrestricted Docker
      socket access or arbitrary root scripts are not the default model.
- [ ] Build an official reference extension after the platform contract is
      stable. A guided access-rules extension is a candidate: professionals can
      already use the expert editor today, while a future guided surface must
      conflict-check zones, forwarding, and expert configuration before it can
      activate.

---

## 0.1.0-alpha.2 — reproducible public alpha

Goal: an external tester can install a named RootGuard version and report a
reproducible problem without relying on local `dev` images.

### Already delivered

- [x] Control plane split into WebApp, Core, and isolated Updater
- [x] Guided AIO deployment of Unbound and AdGuard Home
- [x] HttpOnly sessions, same-origin write checks, and internal bearer token
- [x] DNSSEC-validating AdGuard → Unbound chain without public fallback
- [x] Unbound preview, `unbound-checkconf`, history, diagnostics, and rollback
- [x] Guided local A/AAAA/CNAME zones and guarded expert configuration
- [x] German and English WebGUI
- [x] Data-plane and paired Core/WebApp update foundations
- [x] AGPL-3.0-or-later licensing and separate trademark notice

### Delivered release gate

- [x] Move Updater into a versioned component repository and pin it from the
      main repository
- [x] Commit clean component revisions and a reproducible root Compose model
- [x] Publish versioned `amd64` and `arm64` images to GHCR
- [x] Replace release `latest` references with readable version tags plus
      recorded immutable multi-architecture manifest digests
- [x] Run clean-install tests on Linux `amd64`, Linux `arm64`, and Docker Desktop
      ([issue #39](https://github.com/foxly-it/rootguard/issues/39))
- [x] Test an actual paired Core/WebApp update and a deliberately failed rollback
- [x] Add typed diagnostics for occupied DNS ports, invalid host addresses,
      missing Compose, failed pulls, and interrupted deployment recovery
- [x] Publish release notes, upgrade notes, known limitations, and checksums
- [x] Run the complete integration workflow from a clean GitHub runner

Published: Git tag and GitHub pre-release `v0.1.0-alpha.2`. The remaining
unchecked items are alpha hardening work and stay ahead of beta readiness.

### 0.1.0-alpha.3 — visibility and interface quality

- [x] Add privacy-preserving live Stack and AdGuard Home dashboard metrics
- [x] Refresh dashboard and Stack Center state automatically
- [x] Move long logs, configurations, and directive details into large,
      scrollable views
- [x] Align guided Unbound actions and fix stale Advisor profile results
- [x] Update the responsive public website and bilingual documentation

Published: Git tag and GitHub pre-release `v0.1.0-alpha.3`. The clean
GitHub-runner release smoke test passed login, guided AIO installation,
recursive DNS resolution, and DNSSEC rejection.

### 0.1.0-alpha.4 — resolver safety and transparent filtering

- [x] Upgrade Unbound to 1.25.2 and preserve writable state across updates with
      a stable non-root volume identity
- [x] Add guided serve-expired, DNSSEC cache, EDNS buffer, resource-profile,
      and temporary diagnostic-log controls
- [x] Apply privacy-oriented AdGuard Home DNS defaults during guided setup
- [x] Show local AdGuard filter diagnostics without visiting test websites or
      exposing client/query details
- [x] Add a collapsible symbol-only navigation sidebar and align public preview
      icons with the live dashboard
- [x] Publish and smoke-test digest-pinned `amd64` and `arm64` component images

Published: Git tag and GitHub pre-release `v0.1.0-alpha.4`. The release smoke
test verifies guided installation, recursive DNS, and DNSSEC rejection.

### Current development slice — trustworthy Stack Center

- [x] Show real runtime state, health, image reference, immutable image ID,
      start time, restart count, and published ports for every managed service
- [x] Translate Docker state into plain-language operator guidance without
      exposing raw daemon output
- [x] Add bounded, redacted service logs with explicit retention
- [x] Persist bounded update, rollback, and cleanup history across restarts
- [x] Pin release images by recorded digest and document retention policy
- [x] Inspect all five allowlisted services and expose OCI version, build
      revision, creation time, source, and manifest-digest pinning
- [x] Distinguish immutable releases, mutable tags, local builds, and incomplete
      release metadata in the bilingual Stack Center
- [x] Verify signed Core/WebApp release attestations before presenting an image
      as cryptographically trusted

### Completed development slice — reproducible Unbound on Debian Stable

Goal: keep the patched Unbound security floor without using Debian
Forky/Sid as the production runtime base.

- [x] Build a pinned Unbound `1.25.2+` release reproducibly from its upstream
      source on Debian 13 Slim instead of installing the Forky/Sid package
- [x] Verify the upstream archive by SHA-256, pin the multi-architecture base by
      digest and direct dependencies by exact version, verify Debian packages
      through signed repository metadata, and publish the resolved graph in the
      SBOM
- [x] Separate the build stage from a minimal Debian 13 runtime stage while
      preserving the stable non-root `100:101` identity and writable RFC5011
      trust anchor
- [x] Publish complete SBOM, source version, build revision, checksums, and
      provenance for `amd64` and `arm64`
- [x] Verify configuration compatibility, recursive DNS, DNSSEC validation,
      trust-anchor writes, and health checks on `amd64` and `arm64`
- [x] Verify Core-managed update and rollback with the source-built image on
      native `amd64` and `arm64` installations
- [x] Document the rebuild and security-update procedure so a newer supported
      Unbound release can be adopted without silently changing the base system

Exit: RootGuard runs a current, traceable Unbound build on Debian Stable and no
production image depends on Forky/Sid packages.

### Next development slice — accessible WebGUI navigation and appearance

Goal: make every RootGuard area and setting quickly reachable while keeping the
application shell consistent, readable, responsive, and fully keyboard
operable.

- [x] Rework the header as a coherent utility bar whose search, documentation,
      GitHub, language, theme, user, and sign-out controls share consistent
      sizing, borders, spacing, icons, hover states, and visible focus states.
      Search itself is added by the global-search items below and will
      inherit the same control style
      ([rootguard-webapp#60](https://github.com/foxly-it/rootguard-webapp/pull/60))
- [x] Move language, appearance, and sign-out into an accessible user menu;
      retain direct Docs and GitHub actions on desktop and consolidate utility
      actions into the menu on narrow viewports
      ([rootguard-webapp#62](https://github.com/foxly-it/rootguard-webapp/pull/62))
- [x] Add a global, local-only search covering pages, Unbound tabs, all guided
      settings, configuration views, diagnostics, history, and important
      actions in both supported languages, including technical directive names.
      Opens and focuses from its visible header control, the `S` key, and
      `Ctrl`/`Cmd` + `K`; ignores shortcuts while typing and supports complete
      keyboard navigation, `Escape`, focus restoration, and reduced motion
      ([rootguard-webapp#64](https://github.com/foxly-it/rootguard-webapp/pull/64))
- [x] Navigate search results to the correct page and tab: Unbound's active
      tab is now URL-addressable (`/unbound/:section`, deep-linkable and
      reload-safe) and every Unbound search entry points at its actual tab
      instead of only the bare page
      ([rootguard#106](https://github.com/foxly-it/rootguard/pull/106))
- [x] Reveal the relevant section within the destination tab and place focus
      there, without changing a setting directly from the result list -
      search entries now carry a `#unbound-section-...` hash alongside
      their tab route; landing scrolls to and focuses that element, and
      auto-expands it if it's a collapsed panel like the version history
      ([rootguard#107](https://github.com/foxly-it/rootguard/pull/107))
- [x] Replace hard-coded theme colours with semantic design tokens and offer
      persistent System, Light, and Dark modes, with the system preference as
      the default for users who have not made a selection. Covers the app
      shell, login, Dashboard, Setup, Stack Center, AdGuard, and Unbound
      settings; code/config viewers (expert editor, live-config, logs) stay
      dark by design, like a code block. Box-shadow color and intensity are
      themed the same way (theme-aware `--shadow-ink`/`--shadow-scale`
      tokens) so light mode reads as a crisp lift instead of a dark-tuned
      grey halo
      ([rootguard-webapp#54](https://github.com/foxly-it/rootguard-webapp/pull/54),
      [#56](https://github.com/foxly-it/rootguard-webapp/pull/56),
      [#58](https://github.com/foxly-it/rootguard-webapp/pull/58),
      [#72](https://github.com/foxly-it/rootguard-webapp/pull/72))
- [x] Move the sidebar collapse control to its bottom edge, default new desktop
      sessions to the collapsed icon view, and preserve an existing explicit
      local preference
      ([rootguard-webapp#68](https://github.com/foxly-it/rootguard-webapp/pull/68))
- [x] Keep sidebar navigation and its collapse control visible while long
      settings pages scroll; allow the navigation region itself to scroll when
      viewport height is insufficient and keep mobile navigation fully labelled
      ([rootguard-webapp#70](https://github.com/foxly-it/rootguard-webapp/pull/70))
- [x] Add sticky in-page navigation for long settings sections where it
      materially improves orientation and connect its destinations to global
      search results - a sticky section nav appears under the tab strip for
      Resolver, Local DNS & forwarding, and Advanced (each has 3+ distinct
      sub-sections), with scroll-spy highlighting of the currently visible
      section
      ([rootguard#107](https://github.com/foxly-it/rootguard/pull/107))
- [x] Verify responsive behaviour, 200% browser zoom, WCAG 2.2 AA contrast,
      screen-reader names, focus order, tooltips, menus, shortcuts, and both
      colour themes in German and English - automated axe-core scans across
      every page, both themes, both languages, and several interactive
      states, plus scripted checks for focus order/trapping, keyboard
      shortcuts, tooltip-on-focus, and 200%/400%-zoom reflow. Found and
      fixed four color-contrast design tokens that failed 4.5:1 once
      composited over real card backgrounds (not just checked against flat
      `--surface`), a blanket-opacity dimming that silently broke
      descendant text contrast, eight keyboard-inaccessible scrollable
      `<pre>` blocks, and a dialog (`ContentModal`) that didn't trap focus
      ([rootguard#109](https://github.com/foxly-it/rootguard/pull/109))

Exit: all pages and settings are discoverable without navigating the complete
sidebar manually, and the application shell remains readable and operable with
keyboard, screen reader, zoom, mobile viewport, and light or dark appearance.

---

## 0.2 — useful Unbound administration

Goal: common home, lab, and small-business resolver tasks work through guided
forms. Users should not need the expert editor for normal DNS administration.

The detailed ownership and directive plan lives in
[`docs/unbound-configuration-roadmap.md`](docs/unbound-configuration-roadmap.md).

### Guided configuration

- [x] QNAME minimisation, prefetch, serve-expired, cache TTLs, and threads
- [x] Local A, AAAA, and CNAME records
- [x] Conditional forwarding zones with multiple ordered servers
- [x] Private domains and RFC1918 reverse-zone handling ([#41](https://github.com/foxly-it/rootguard/issues/41))
- [x] Keep Unbound client access fixed to the internal RootGuard network;
      end-client CIDR policy belongs to AdGuard Home by design
- [x] IPv4/IPv6 operating mode with capability and connectivity checks
      ([#43](https://github.com/foxly-it/rootguard/issues/43))
- [x] Cache memory sizing derived from an explicit resource profile
- [x] Serve-expired TTL and client timeout controls
- [x] Prefetch-key and aggressive NSEC controls with compatibility guidance
- [x] EDNS buffer size with safe default `1232` and validation
- [x] Privacy-safe logging level and temporary diagnostic logging
- [x] Expand Local DNS into a zone-centred host inventory: create a zone once,
      then add, rename, remove, and bulk-edit devices and servers by hostname
      plus IPv4/IPv6 address, with optional forward-confirmed PTR generation.
      The typed backend model landed first: `LocalZone`/`LocalHost` in
      `rootguard-core/internal/unbound/settings.go`, validated (canonical
      zone/hostname syntax, per-zone and total host limits, address-family
      and reserved-address checks, one PTR claim per address across zones)
      and rendered as `local-zone`/`local-data`/`local-data-ptr` directives
      through the existing `Preview`/`Apply`/`History`/`Restore` lifecycle
      unchanged - no new activation path
      ([rootguard#129](https://github.com/foxly-it/rootguard/pull/129), closes
      [#127](https://github.com/foxly-it/rootguard/issues/127)). The
      guided-zones frontend (`UnboundGuidedZones.tsx`) is now migrated onto
      this typed model instead of its old JSON-in-a-comment scheme inside
      the custom expert config - per the decision in
      [#131](https://github.com/foxly-it/rootguard/issues/131), CNAME
      records and per-host TTL are dropped from the guided UI entirely (the
      typed model was never scoped to support them; the rare CNAME/TTL case
      routes to the unrestricted expert editor instead)
      ([rootguard#167](https://github.com/foxly-it/rootguard/pull/167))
- [x] Import local hosts through bounded `in-addr.arpa`/`ip6.arpa` discovery and
      optional router adapters, beginning with the documented FRITZ!Box TR-064
      host list; keep router credentials server-side and never persist them in
      browser storage or generated Unbound configuration. The FRITZ!Box
      adapter landed first: `rootguard-core/internal/routerimport` speaks the
      standard TR-064 `Hosts:1` service (`GetHostNumberOfEntries` +
      a bounded `GetGenericHostEntry` loop, capped at 256 entries), answers
      an HTTP Digest challenge (RFC 7616, MD5/qop=auth) when the router
      requires one and tolerates one that doesn't, and never persists the
      submitted credentials - they're used for exactly one discovery
      request and discarded. Verified live against a real FRITZ!Box 6690
      Cable (firmware 267.08.25): both the unauthenticated and the
      Digest-challenged path. The same import card now also supports
      router-independent PTR discovery over operator-supplied private IPv4
      and unicast IPv6 prefixes. Prefixes and the combined request are capped
      at 256 unique addresses, lookups run with 16 workers inside a 15-second
      budget, and results remain unselected drafts until the existing
      preview/validate/activate lifecycle is completed. Additional vendor
      adapters can reuse the normalized discovery contract but are not
      required for this router-independent path
      ([rootguard#133](https://github.com/foxly-it/rootguard/pull/133),
      closes [#132](https://github.com/foxly-it/rootguard/issues/132);
      router-independent discovery tracked in
      [#184](https://github.com/foxly-it/rootguard/issues/184))
- [x] Make every host import preview-only until the operator selects entries;
      canonicalise names and addresses, detect duplicates and conflicts with
      guided and expert records, and use the existing preview, validation,
      versioning, activation-health, and rollback lifecycle. Shipped as a
      "Router import" card on Unbound's Local DNS & forwarding tab - the
      first UI for the typed `LocalZone`/`LocalHost` model from #129:
      discovered hosts start unselected, hostnames are editable per row
      before import (renaming a device the operator doesn't like the
      discovered name for), and selection merges into a named zone through
      the same preview → validate → activate lifecycle as every other
      guided Unbound setting. Duplicate-hostname detection is scoped to the
      target zone itself; the cross-system gap against
      `UnboundGuidedZones.tsx`'s own records no longer exists now that both
      surfaces write the same typed `local_zones` field (#131, below)
      ([rootguard#137](https://github.com/foxly-it/rootguard/pull/137))

### Fixed secure base

- [x] Document and test DNSSEC hardening, glue/referral hardening, identity and
      version hiding, unwanted-reply threshold, root hints, and trust-anchor
      maintenance - added the previously-missing `unwanted-reply-threshold`
      (upstream's own recommended defensive-reset value), documented every
      fixed-base directive's purpose and test coverage in
      `docs/unbound-configuration-roadmap.md`, and added CI checks for what
      wasn't tested yet: `hide-identity`/`hide-version` now get a real CHAOS
      `id.server`/`version.server` probe (previously only declared, never
      exercised), plus a presence check confirming every hardening directive
      actually ships in the built image. `harden-referral-path` and
      `use-caps-for-id` remain deliberately deferred (documented why), not
      silently dropped
      ([rootguard#122](https://github.com/foxly-it/rootguard/pull/122))
- [x] Show fixed base protections in the WebGUI without making unsafe values
      freely editable - two read-only surfaces, neither editable: the
      Advanced tab's "LIVE · READ ONLY" panel opens the actual running
      `/etc/unbound/unbound.conf` (hardening directives included) in a
      focused, keyboard-accessible view, and the expert editor's permanently
      visible immutable-base panel (already checked below,
      [rootguard-webapp#74](https://github.com/foxly-it/rootguard-webapp/pull/74))
      shows the same directives inline next to guided settings. Both are raw
      config dumps rather than an explained, per-directive protections list -
      that framing would be a follow-up, not what this item asked for
- [x] Validate generated configurations against every supported Unbound image -
      `unbound-checkconf`, the DNSSEC/identity/version smoke tests, and the
      trust-anchor volume-compatibility check now run natively on both
      supported architectures (`amd64`, `arm64`) before the multi-arch image
      is pushed. Previously only `amd64` ever ran them; the `arm64` half of
      `linux/amd64,linux/arm64` shipped on the assumption that a successful
      QEMU-emulated build implied a working image, with no functional
      verification of its own
      ([rootguard#121](https://github.com/foxly-it/rootguard/pull/121))

### User experience and safety

- [x] Compact the Unbound resource-profile selector and align the trusted
      private-domain input/action as one control row. Desktop now uses a
      bounded 38 px profile selector and equal 46 px domain controls; mobile
      retains the full accessible action name while reducing the add action to
      a square icon button
      ([rootguard#216](https://github.com/foxly-it/rootguard/issues/216))
- [x] Fix the Dashboard service KPI so its active and total counts come from
      the same allowlisted five-service inventory; a healthy full stack now
      shows `5 / 5` instead of a stale `5 / 2`
      ([rootguard-webapp#50](https://github.com/foxly-it/rootguard-webapp/pull/50))
- [x] Shared guided workflow: draft → explanation → preview → validate → activate -
      extracted `useUnboundDraftWorkflow` (`rootguard-webapp/frontend/src/
      hooks/`) and a shared `GuidedFlowSteps` component, and migrated guided
      zones, router import, private domains, and forward zones onto both -
      four of the five places this pattern was independently implemented.
      The hook owns the fetch/reload lifecycle, the concurrency-checked
      preview/activate calls, and busy/message/error state, while staying
      generic over what each consumer edits: a pluggable comparator handles
      private domains' and forward zones' whole-object concurrency check
      vs. guided zones' and router import's field-scoped one, and an
      `onPreviewStart` hook point preserves forward zones' parallel
      reachability probe. All four now show the same step indicator that
      previously only guided zones had. The main settings form (the fifth
      place) is deliberately not migrated - it edits the whole settings
      object as its own draft with no separate source/candidate/concurrency
      check or confirm-before-activate step, a genuinely different shape
      that would have forced the abstraction rather than fit it
      ([rootguard#168](https://github.com/foxly-it/rootguard/pull/168))
- [x] Add a fullscreen mode to the expert configuration editor, and show the
      immutable base configuration's already-active directives inline
      (greyed out, read-only) instead of only in a separate popup, so expert
      users get a complete picture of the effective configuration without
      leaving the editor. The base config panel is permanently visible next
      to the editor, not behind a click-to-expand disclosure
      ([rootguard-webapp#74](https://github.com/foxly-it/rootguard-webapp/pull/74),
      [rootguard#101](https://github.com/foxly-it/rootguard/issues/101))
- [x] Conflict detection across every currently guided Unbound surface and
      expert text -
      more already ships than this line credited: `validateGuidedConflicts`
      (`rootguard-core/internal/unbound/custom.go`) cross-checks guided
      forward zones, private domains, and the RFC1918/local-zone inventory
      against expert-text `forward-zone`/`private-domain`/`local-zone`
      blocks on every activation. Cross-zone uniqueness is now enforced for
      both PTR addresses (`TestLocalZonePTRRejectsDuplicateAddressAcrossZones`)
      and hostnames (`TestLocalZoneHostnameRejectsDuplicateAcrossZones`)
      across the whole typed host inventory, not just within one zone -
      checked both client-side (guided zones, router import) and
      server-side (`validateLocalZones`) on every save/activation. The
      legacy `UnboundGuidedZones.tsx` JSON-in-comment scheme this line used
      to worry about no longer exists (migrated to the typed model in
      #167). Access rules deliberately remain expert-only before 1.0, with
      the existing advisor warning and high-risk catalog entry. A guided
      access-rules surface is therefore not part of this pre-1.0 gate; it is
      a possible reference extension for the post-1.0 architecture tracked
      in [#186](https://github.com/foxly-it/rootguard/issues/186), where
      conflict detection becomes an acceptance criterion of that extension
- [x] Import/export of the complete logical resolver configuration - guided
      settings and expert custom config bundled together as a single
      downloadable/uploadable JSON file (`GET /api/unbound/export`,
      `POST /api/unbound/import/preview`, `POST /api/unbound/import`), with
      a new "Import / export configuration" card on the Advanced tab
      (`UnboundConfigTransfer.tsx`). Distinct from the existing per-version
      history, which is for in-place rollback, not for taking a
      configuration to another instance or a backup. The import path
      validates settings and custom config as the *pair* they'll become
      (`Manager.PreviewBundle`/`ApplyBundle`, reusing the existing atomic
      `applyStateLocked`) rather than each against the other's stale
      on-disk value - otherwise an import resolving an existing
      guided/expert conflict by changing both sides at once would be
      spuriously rejected
- [x] Import an existing hand-written `unbound.conf` - delivered at full
      roadmap scope, per explicit user direction (2026-08-10) to build this
      item completely rather than ship a leaner classify-only version.
      `ImportUnboundConf` (`rootguard-core/internal/unbound/import.go`) parses
      an arbitrary unbound.conf into clauses and classifies every directive:
      fixed-base directives are filtered (or rejected as a conflict if the
      imported value differs, e.g. `hide-version: no`), the ~12 flat
      `server:` scalars with an existing guided setting (qname-minimisation,
      prefetch(-key), aggressive-nsec, serve-expired(-ttl/-client-timeout),
      cache-min/max-ttl, num-threads, edns-buffer-size, verbosity) are applied
      to a candidate `Settings`, dangerous clauses (`remote-control`,
      `python`, `dynlib`, `dnstap`) are blocked outright, and everything else
      allowed for manual expert input is offered for expert-config adoption -
      including whole clause blocks that don't map cleanly onto a guided
      structure, which is already exactly what pasting them into the expert
      editor by hand does today. `forward-zone` blocks now DO reverse-map
      onto guided `ForwardZone`s when they're a clean fit (a name, one or
      more `forward-addr`, optionally `forward-first` - anything else, e.g.
      `forward-tls-upstream`, keeps the whole block as expert-adoptable
      instead of silently dropping what doesn't fit). The zone-scoped
      `domain-insecure`/`private-domain` opt-ins are resolved against the
      zones just extracted: matching a zone sets that zone's
      `AllowUnsigned`/`AllowPrivateAddresses`, a `private-domain` matching no
      zone joins the global guided list instead, and a `domain-insecure`
      matching no zone (meaningless outside that context) is offered for
      expert adoption. `local-zone "static"` clauses (plus their contiguous
      `local-data`/`local-data-ptr` lines, matching exactly the shape
      `Settings.Render()` itself produces) now reverse-map onto the typed
      host inventory too when they're a clean fit: every `local-data` line
      is an A/AAAA record shaped `<hostname>.<zone>`, and every
      `local-data-ptr` line's address and target match a host already
      established in the same group. CNAME records (out of scope for the
      guided model, see #131), a mismatched PTR, a non-`static` type, or one
      of RootGuard's own RFC1918 reverse-zone names (which would otherwise
      produce an empty, invalid zone) all fall back to per-line expert
      adoption instead of guessing wrong. RFC1918 reverse-zone policy
      reverse-maps too: a network's reverse-zone lines (one for
      `10.0.0.0/8`, sixteen for `172.16.0.0/12`, one for `192.168.0.0/16`)
      apply as that network's `ReverseZonePolicy` only when *every* expected
      zone name is present exactly once with a consistent, recognized type
      (`static`→nxdomain, `transparent`→transparent) - a partial set,
      duplicate, or unrecognized type keyword leaves those lines unconsumed
      rather than guessing. `do-ip4`/`do-ip6`/`prefer-ip6` reverse-map onto
      `NetworkMode` only when all three appear exactly once in one of the
      three combinations `Settings.Render()` itself produces.
      `rrset-cache-size`/`msg-cache-size` reverse-map onto `ResourceProfile`
      only when both appear exactly once and match one of RootGuard's three
      preset size pairs exactly. Any incomplete or inconsistent set among
      these three falls back to a blocked finding naming the specific
      guided concept it belongs to, not silent expert adoption (which would
      just be rejected downstream anyway) or a guess. New
      `UnboundConfImport.tsx` (Unbound Advanced tab) drives paste-or-upload
      → classify → the existing bundle preview/activate lifecycle from the
      import/export feature above. Hardened against a real hand-written
      config (11 hosts, private-address/access-control lines, a redirect
      zone, a forward-zone, a remote-control block) reported live: fixed a
      PTR-target trailing-dot mismatch that silently misclassified an
      otherwise-clean zone as expert instead of guided, a custom-config
      accumulation bug where re-classifying after an earlier activation
      produced a genuine `unbound-checkconf` syntax error, and a follow-on
      duplicate-zone error on re-import - zones now merge by name, making
      re-import idempotent. The guided local-zone host list also moved from
      an inline chip row to a proper Hostname/IPv4/IPv6/PTR table
      (`UnboundGuidedZones.tsx`), since the same real-world zone made an
      11-row wrapped chip list hard to scan
- [x] Scenario tests for home network, VLANs, split DNS, IPv6-only local records,
      broken upstreams, and DNSSEC failures - all 6 now covered as named,
      end-to-end tests (`rootguard-core/internal/unbound/
      scenario_integration_test.go`, build-tag-gated so `go test ./...`
      stays Docker-free) that construct real `Settings` values exactly the
      way a user's guided WebGUI configuration would, render them through
      the actual production `Settings.Render()`, apply them to a real
      running `rootguard-unbound` container, and verify with real `dig`
      queries - not string-matching rendered config. Wired into CI as a new
      `scenario-tests` job in `ci-unbound.yml`, gating image publishing
      alongside the existing fixed-base checks. Two design corrections
      found only by testing against the real resolver rather than reasoning
      about it: an `AllowUnsigned`/split-DNS check that initially relied on
      a real third-party domain's DNSSEC status turned out to test the
      wrong thing (`dnssec-failed.org` is deliberately signed-with-broken-
      signatures, not unsigned, so `domain-insecure` never applied to it
      regardless) and was redesigned around forwarding behavior instead;
      and the broken-upstream scenario originally expected a fast SERVFAIL,
      but a forward target that's silently dropped rather than actively
      refused turns out to have no effective timeout in Unbound's default
      retry behavior at all (still pending after 5 minutes in manual
      testing) - redesigned to verify what actually matters operationally,
      that one stuck forward zone doesn't block unrelated queries, instead
      ([rootguard#183](https://github.com/foxly-it/rootguard/pull/183))

Exit: normal resolver administration no longer depends on raw Unbound syntax.

---

## 0.3 — AdGuard integration without feature duplication

Goal: RootGuard integrates AdGuard Home safely and explains the complete DNS
path without rebuilding functions already maintained by AdGuard Home.

Product boundary:

- AdGuard Home remains the primary interface for filter lists, allow/deny
  exceptions, clients, query logs, statistics, and per-client policy.
- RootGuard owns installation, protected access, the fixed Unbound upstream,
  cross-service health, updates, backup/recovery, and plain-language guidance.
- A native AdGuard function is added to RootGuard only when RootGuard must
  coordinate it with another component or enforce an appliance safety boundary.

### Integration work

- [x] Protected same-origin gateway to the native AdGuard Home interface
- [x] Automatic private bootstrap with generated credentials and fixed Unbound
      upstream
- [x] Show AdGuard version, configuration state, protected upstream, and
      gateway availability together in RootGuard - configuration state,
      protected upstream, and gateway reachability were already shown in
      the AdGuard status panel; AdGuard's own reported version (previously
      discarded from `/control/status`) is now surfaced there too
- [x] Cross-service diagnostics showing Client → AdGuard → Unbound → DNSSEC -
      verified the container network topology first: AdGuard was reachable by
      container name (`rootguard-adguard`) on the `rootguard-dns` network,
      and only `rootguard-unbound`'s container has both DNS tooling (`dig`)
      and network line-of-sight to it, so the new "Path diagnostics" check
      reuses that same container - same pattern as Unbound's own
      resolution/DNSSEC checks, just targeting AdGuard's listener
      (`rootguard-adguard:53`) instead of Unbound's own port. Surfaced as a
      second card on the Unbound Overview tab, next to the existing "Live
      diagnostics"
      ([rootguard#160](https://github.com/foxly-it/rootguard/pull/160)).
      AdGuard being unpinned on that network at the time turned out to be a
      real bug, not just an implementation detail - fixed in the 0.4 entry
      below
- [x] Contextual links from RootGuard guidance to the relevant native AdGuard
      page without exposing its administration port: deep-links through the
      existing protected `/adguard-ui/` proxy (never the raw admin port) to
      general settings, DNS settings, and the blocklist page, placed next
      to the status rows and filter-test results they're each relevant to
      ([rootguard#150](https://github.com/foxly-it/rootguard/pull/150))
- [x] Master filtering on/off toggle surfaced directly in RootGuard, next to
      the Live-Filterprüfung diagnostic that exercises it - a deliberate,
      explicit exception to the "AdGuard remains the primary interface"
      boundary above, made so operators don't need a second admin surface
      for the single most common toggle; every other filtering concern
      (list management, allow/deny rules, per-client policy) still routes
      through native AdGuard Home via the contextual links above
      ([rootguard#155](https://github.com/foxly-it/rootguard/pull/155))
- [x] Document backup and restore ownership for AdGuard configuration, work
      data, query history, and filter state (`docs/architecture.md`): the
      two dedicated volumes (`rootguard-adguard-config`,
      `rootguard-adguard-work`) are already covered by the existing
      pre-update backup/rollback mechanism, but that's an internal
      update-time safety net, not an operator-triggered or long-term
      retained restore point - documents the current split of
      responsibility and a manual `docker run`-based volume backup for
      anything beyond it, pending the native backup/export work in 0.4
      ([rootguard#151](https://github.com/foxly-it/rootguard/pull/151))
- [x] Compatibility tests against every supported AdGuard Home release -
      RootGuard only ever ships two channels (the pinned stable image and
      the beta channel, per the guided setup's `adguard_channel` option), so
      "every supported release" means both of those, not an unbounded
      version matrix. A new `ci-adguard-compat.yml` workflow runs the full
      bootstrap → status → filtering-toggle → DNS-resolution → filter-check
      surface against each, on a matrix so a channel-specific break doesn't
      mask the other, on pushes/PRs touching the AdGuard integration code
      plus a weekly schedule to catch upstream drift independent of
      RootGuard's own commits
      ([rootguard#158](https://github.com/foxly-it/rootguard/pull/158))
- [x] Ship a self-hosted, RootGuard-branded blocked-request landing page as a
      new `rootguard-blockpage` monorepo directory (same pattern as
      core/webapp/unbound/updater: own Dockerfile, own path-filtered CI),
      AGPL-3.0-or-later like the rest of RootGuard. Static HTML/CSS/JS, auto
      dark/light, no external dependencies. Configures AdGuard Home's
      "custom IP for blocked hosts" automatically during guided setup,
      optional and enabled by default (a dedicated Setup toggle lets users
      who explicitly don't want it disable it; recommended by default). HTTP
      only - a DNS-level blocking IP cannot present a valid TLS certificate
      for arbitrary blocked domains, so HTTPS requests show the browser's own
      certificate-warning interstitial instead of the page; this is AdGuard
      Home's documented `custom_ip` behaviour, not a RootGuard limitation
      ([rootguard#98](https://github.com/foxly-it/rootguard/pull/98)).
      Redesigned after initial feedback that the first version leaned too
      much on the WebGUI's own dashboard visual language (hero/KPI grid, red
      "danger" framing); the shipped design instead follows AdGuard Home's
      own brand green as a positive "protection working" signal, with a
      restrained, high-contrast, single-accent layout
      ([rootguard#100](https://github.com/foxly-it/rootguard/pull/100)).
      Setup's blockpage toggle now links to a bundled static preview so an
      operator can see the actual page before (or without) deploying it;
      building the preview surfaced a real WCAG AA contrast failure in the
      shipped page itself (light-theme `--accent` too light for both
      white-on-green buttons and green-on-white links), fixed in the same
      change - the earlier blockpage work and the separate WebGUI-wide
      accessibility audit had each verified their own surface but neither
      had scanned this component
      ([rootguard#112](https://github.com/foxly-it/rootguard/pull/112))
- [x] The "why blocked" reasons on the landing page were a static,
      always-all-checked list - not tied to what actually matched for this
      request. Three-part fix: (1) a narrow, rate-limited nginx proxy on the
      blockpage itself (`/api/reason`) that asks AdGuard's own `check_host`
      for the real verdict on the specific host that got sinkholed here,
      authenticated with a derived token Core publishes - never the raw
      AdGuard admin credentials - to a dedicated volume, since the blockpage
      is the one component here reachable, unauthenticated, by anyone who
      gets DNS-sinkholed to it
      ([rootguard#117](https://github.com/foxly-it/rootguard/pull/117));
      (2) wire the real per-request reason into five honest category cards
      (the previous four conflated two AdGuard reasons that `check_host`
      can't actually distinguish, and had no card at all for AdGuard's
      "blocked service"/parental-control verdicts)
      ([rootguard#118](https://github.com/foxly-it/rootguard/pull/118));
      (3) a visual redesign bringing back cards and motion in the spirit of
      the main WebApp, deliberately not the #98 hero/KPI treatment #100
      already rejected - same restrained AdGuard-green accent, borrowed card/
      shadow/animation *mechanics* rather than the WebApp's teal/danger
      *palette*
      ([rootguard#119](https://github.com/foxly-it/rootguard/pull/119))
- [x] Follow-up redesign after live user feedback on #119: the five-card grid
      still made someone read past four irrelevant cards to find the one that
      actually applied. Replaced with a narrative layout - "Diese Seite ist
      blockiert - {reason}." as the headline, the domain and reason spelled
      out in one sentence - alongside a commissioned RootGuard fox mascot
      illustration (green cloak/shield matching the brand accent, root-vein
      motif tying "fox" to "root network"), closer in spirit to how AdGuard
      Home's own block page reads. The two-tier fallback (`FilteredBlackList`
      alone can't distinguish ads from generic threat-list hits) collapses
      into the sentence's `Filterliste` phrasing rather than a card label;
      unmatched/failed lookups keep the original generic headline, still the
      default render
      ([rootguard#125](https://github.com/foxly-it/rootguard/pull/125))

Exit: AdGuard Home remains recognisably native while RootGuard provides a safe,
coherent appliance lifecycle around it.

---

## 0.4 — operations, backup, and recovery

Goal: an operator can understand failures and recover the appliance without
manual Docker forensics.

- [x] Bounded and redacted logs for every managed component - the dedicated
      Logs & Diagnostics page selects every allowlisted service, offers local
      text/severity filters, optional refresh and a downloadable redacted
      report, while Core retains the 30-minute, 100-line, and 64-KiB bounds
      ([rootguard#201](https://github.com/foxly-it/rootguard/pull/201)). Live
      LXC verification additionally separated the five-service log-read
      allowlist from the deliberately narrower AdGuard/Unbound lifecycle-action
      allowlist, so control-plane logs are readable without granting browser
      start/stop/restart authority over those containers
      ([rootguard#210](https://github.com/foxly-it/rootguard/pull/210))
- [x] Real component versions, image digests, uptime, and health reasons
- [x] Persistent, bounded update and rollback history for data and control plane
- [x] Keep the operations UI scannable as capabilities grow - import scope is
      explicit on the Backups page and searchable down to each workflow; Stack
      keeps routine health and actions visible, folds release metadata into
      technical details, and loads Docker cleanup only on demand
      ([rootguard#203](https://github.com/foxly-it/rootguard/pull/203),
      [rootguard#206](https://github.com/foxly-it/rootguard/pull/206))
- [x] Fixed a real production failure mode where an Unbound image update -
      and its automatic rollback - could both fail with "Address already in
      use", leaving Unbound down: AdGuard was deliberately unpinned on the
      `rootguard-dns` network (see the 0.3 cross-service diagnostics entry
      below), which let it grab Unbound's reserved static address whenever
      that address happened to be free (e.g. mid-update, or if AdGuard's own
      container was recreated while Unbound was down for any reason) -
      permanently blocking Unbound from reclaiming its own address until
      manually repaired. The controller's own `docker network connect` to
      that network had the identical gap. Both AdGuard and the controller
      now get their own pinned, non-conflicting addresses, matching Unbound
      and the blockpage
      ([rootguard#182](https://github.com/foxly-it/rootguard/pull/182))
- [x] Configurable backup retention with storage-usage visibility - the Core
      updater now retains a configurable 2–50 recognized pre-update restore
      points per service (default 5), persists the policy separately from
      update state, and enforces it only after an update/rollback lifecycle or
      an explicitly confirmed settings change. The Stack Center shows total
      and per-service counts, logical bytes, and newest timestamps. Pruning
      requires the canonical timestamp/service layout plus a manifest matching
      the allowlisted service and container; unknown data and symlinks are
      measured separately and never deleted
      ([rootguard#189](https://github.com/foxly-it/rootguard/pull/189))
- [x] Safe automatic post-update cleanup:
      retain the active and previous successful image, prune only older image
      IDs recorded by RootGuard, and never call global Docker prune commands
- [x] Prune only unused transient volumes carrying an explicit RootGuard cleanup
      label; permanently protect configuration, data, session, state, and backup
      volumes
- [x] Record every automatic cleanup in the update history and expose
      a clear no-op result when nothing can be removed safely
- [x] Add an optional manual cleanup preview with a reclaimed-space estimate -
      the Stack Center lists only obsolete image IDs from RootGuard's own
      successful update history and unused volumes carrying the explicit
      cleanup label, shows Docker's per-resource `UniqueSize`/volume-size
      estimate, and requires confirmation before deletion. Execution always
      recomputes eligibility, retains two successful images per service, and
      records the result in bounded history; unverifiable resources remain
      protected and global prune commands remain prohibited
      ([rootguard#191](https://github.com/foxly-it/rootguard/pull/191))
- [x] Encrypted or explicitly protected backup export - the dedicated Backups
      page now creates a portable, passphrase-protected age-v1 archive containing a
      versioned, checksummed manifest, RootGuard configuration state, live
      AdGuard Home config/work data, and Unbound runtime state. Browser
      sessions, update-restore history, temporary exports, and external `.env`
      secrets are excluded. Source paths and container names are fixed
      server-side, symlinks are rejected, plaintext staging uses a private Core
      directory and is removed on every exit, and data-plane updates are locked
      out while the artifact is created
      ([rootguard#193](https://github.com/foxly-it/rootguard/pull/193),
      [rootguard#195](https://github.com/foxly-it/rootguard/pull/195))
- [x] Full restore into a clean RootGuard installation - the Backups page now
      validates an encrypted age-v1 export before mutation, including bounded
      upload/expanded size/file count, exact allowlisted paths and regular
      file types, required recovery files, manifest inventory, sizes, and
      SHA-256 checksums. A second apply request repeats all checks, requires an
      explicit confirmation, and is accepted only when installation state plus
      Docker containers, internal volumes, network, target address, and port
      pass a fresh clean-target preflight. Operators can recheck a replacement
      host with a different bind address/port. RootGuard creates stopped
      containers and empty volumes, restores local plus AdGuard/Unbound data,
      normalizes Unbound ownership, then starts and health-verifies the full
      protected DNS chain. Passphrases are never persisted, plaintext staging
      is removed on every exit, and a failed attempt removes its new Docker
      resources and rolls back prior local volume contents
      ([rootguard#199](https://github.com/foxly-it/rootguard/pull/199))
- [x] Pre-update snapshot and post-update restore verification - distinct
      from the operator-triggered portable backup/restore above: the
      *internal, automatic* pre-update snapshot Core takes before replacing
      an AdGuard/Unbound image now carries a SHA-256 checksum per backed-up
      file in its manifest (`rootguard-core/internal/updater/backups.go`).
      A failed update's automatic rollback verifies every checksum before
      trusting the snapshot; a corrupted or partial backup is refused rather
      than silently restored, while the container still lands back on the
      known-good previous image even when the data restore itself is
      refused. A real Docker integration test
      (`rollback_integration_test.go`, `go test -tags integration
      ./internal/updater/... -run TestRollback`) proves both paths against
      an actual container and a real `docker compose` image swap, under its
      own isolated Compose project so it can never interact with a real
      RootGuard deployment; wired into `ci-core.yml` as a dedicated job
      ([rootguard#223](https://github.com/foxly-it/rootguard/issues/223))
- [x] Power-loss and interrupted-write tests for installation and updates -
      every persisted file already used atomic temp+rename except
      `writeBackupManifest`, now fixed to match. Since Go has no
      per-goroutine kill, genuinely simulating a killed process needs a real
      child OS process SIGKILLed mid-operation (the same "TestHelperProcess"
      technique Go's own `os/exec` tests use): both `updater.Manager`
      (`power_loss_integration_test.go`, real Docker, killed between
      `backup()` and the candidate image swap, and again between the swap
      and Verify/rollback) and `installer.Manager`
      (`power_loss_test.go`, Docker-free - `deploy()` hardcodes production
      container/network names with no isolated-project escape hatch, so a
      real-Docker version would risk colliding with an actual deployment;
      the process-persistence layer under test does not depend on the
      containers being real) prove a restarted Manager reports the existing
      interrupted-operation diagnostic instead of a stale in-progress
      status, that whatever was already captured/written before the kill
      stays intact, and that a retried operation afterward completes
      cleanly - the appliance is never left stuck. Wired into `ci-core.yml`;
      the installer test needs no Docker and already runs in the plain
      `go test ./...` job
      ([rootguard#225](https://github.com/foxly-it/rootguard/issues/225))
- [x] Disaster-recovery runbook tested on a separate host -
      `docs/disaster-recovery.md` covers total host loss, a failed update
      that didn't roll back cleanly, lost administrator credentials, a
      stuck deployment/update after a crash, and DNS-outage triage, each
      mapped to the actual existing mechanism rather than introducing new
      recovery capability. The total-host-loss scenario was drilled for
      real: an encrypted backup exported from a live installation was
      restored on an entirely separate, freshly provisioned host with no
      prior RootGuard state, reaching `installed` through all seven restore
      steps, with the replacement host's own resolver independently
      verified (`ad`-flagged recursive resolution, `SERVFAIL` for a broken
      DNSSEC chain) - proof the restored DNS chain actually works, not only
      that files were copied. The drill also surfaced a real gap: the
      backup/restore feature landed on `main` after the published
      `v0.1.0-alpha.7` release, so the current public alpha doesn't have it
      yet - noted directly in the runbook
      ([rootguard#227](https://github.com/foxly-it/rootguard/issues/227))

Exit: backup export/import and failed-update recovery are automated and tested.

---

## 0.5 — security, HTTPS, and accessibility

Goal: the appliance has a documented, reviewable security posture suitable for
a trusted network.

- [x] Enforce a patched Unbound `1.25.2+` security floor before exposing
      serve-expired client-timeout controls
- [x] Built-in HTTPS or a supported reverse-proxy deployment with secure
      defaults - scoped deliberately to documentation, not a RootGuard-native
      TLS implementation: `docs/https-reverse-proxy.md` covers the two hard
      requirements (Host-header passthrough for the same-origin write check,
      `X-Forwarded-Proto` for secure-cookie detection) plus working examples
      for Caddy, Zoraxy, Nginx Proxy Manager, and HAProxy
- [x] Secure-cookie enforcement when HTTPS is active - already implemented
      (`requestIsHTTPS` in `rootguard-webapp/backend/internal/httpapi/auth.go`
      sets the session cookie's `Secure` flag whenever `r.TLS` is set or
      `X-Forwarded-Proto` reads `https`); documented in
      `docs/https-reverse-proxy.md`
- [x] Session inventory and session revocation: `GET /api/auth/sessions` lists
      every active session with a friendly device label, sign-in/expiry
      time, and remote address, without ever returning the actual session
      token; `DELETE /api/auth/sessions/{id}` revokes one by its separate
      opaque id. Reachable from the user menu ("Active sessions"). Sessions
      persisted before this feature (no id/created-at) are backfilled on
      the next load instead of staying permanently unrevocable
- [x] Recovery path for lost administrator credentials
- [x] Authenticated account settings: `POST /api/auth/account` lets the
      logged-in operator rename their account and/or set a new password
      by confirming the current one, reusing the same PBKDF2/
      `credentials.json` persistence the recovery flow already had (now
      extended to also cover the username). Unlike recovery, the calling
      session stays alive and only every *other* session is invalidated.
      Reachable from the user menu ("Account settings")
      ([rootguard#329](https://github.com/foxly-it/rootguard/issues/329))
- [x] Rate limits and audit events for authentication: login and password
      recovery both lock out after 5 failures in a 5-minute window (an
      already-issued lockout blocks even a subsequently correct password,
      rather than letting enough guesses eventually succeed), and a bounded,
      persisted audit log records login success/failure, rate-limiting,
      logout, recovery, and session revocation - visible in the user menu's
      "Active sessions" panel alongside the session inventory
      ([rootguard#148](https://github.com/foxly-it/rootguard/pull/148))
- [x] Rate limits and audit events for destructive actions elsewhere in the
      app: a shared, per-session sliding-window limiter (30 requests / 5
      minutes across every destructive route, not a separate budget per
      route) and audit logging now cover Unbound settings activation,
      history restore, custom-config apply, import apply, and diagnostic
      logging start/stop; service start/stop/restart and image updates;
      backup settings changes, export, and restore; manual cleanup;
      control-plane update install; installation deploy; and AdGuard
      bootstrap and filtering toggle - the same `GET /api/auth/audit` log
      the authentication surface already used
      ([rootguard#219](https://github.com/foxly-it/rootguard/issues/219))
- [x] Threat model covering Docker socket holders, browser, internal networks,
      update supply chain, backups, and the AdGuard gateway
      (`docs/threat-model.md`,
      [rootguard#144](https://github.com/foxly-it/rootguard/pull/144))
- [x] Dependency, container, secret, and static-analysis scans in CI:
      `govulncheck` and `staticcheck` per Go module, `gitleaks` against full
      git history, and `trivy` filesystem scanning (dependency
      vulnerabilities, secrets, and Dockerfile misconfigurations) across the
      whole repository, all gated on pull requests and pushes to `main`
      ([rootguard#143](https://github.com/foxly-it/rootguard/pull/143))
- [x] Keyboard and screen-reader audit of every WebGUI workflow - formal
      re-verification of every current route (the original pass predates
      Login, Backups, and Logs & Diagnostics entirely) plus every
      interactive state added since: automated axe-core across 8 routes x
      2 themes (30 scan combinations, all clean after fixes), scripted
      keyboard walk-throughs of the full login/recovery flow, the
      collapsed-sidebar sub-nav, three independent `ContentModal` focus
      traps (Sessions, AdGuard filter-test, plus the existing search
      modal), and the Zones tab's guided-workflow wizards. Found and fixed
      three real bugs beyond the earlier pass's scope: collapsed-sidebar
      sub-nav links had no accessible name on 3 of 4 Unbound tabs (icon
      only, label hidden via CSS, no `aria-label` fallback - the exact
      pattern the top-level nav items already had); the Overview tab's
      own two sub-nav entries never appeared at all (`sectionsFor()` had
      no case for it); and closing the session-inventory modal silently
      dropped focus to `<body>` instead of the user-menu trigger, the
      same async-unmount race `returnFocusTo` was built for in
      [rootguard#110](https://github.com/foxly-it/rootguard/pull/110) -
      that prop just had no live caller left once Logs became its own
      page ([rootguard#221](https://github.com/foxly-it/rootguard/issues/221))
- [x] WCAG 2.2 AA contrast, focus, labels, and errors review - contrast and
      focus got real fixes in
      [rootguard#110](https://github.com/foxly-it/rootguard/pull/110);
      the labels and errors pass above closed the rest: error text on
      Overview, AdGuard, Stack, Backups, Logs, and the session/audit
      panel was never announced to screen readers (`role="alert"` only
      existed on Login/Setup/Unbound/the 4 guided wizards) - now
      consistent across every page. Two further contrast regressions
      found and fixed: `ContentModal`'s intentionally-always-dark shell
      broke contrast for theme-tokened content once the page itself was
      in light theme (the shell's own literals were already fixed per
      design, but its legacy `--rg-*` token aliases were declared once at
      `:root` and never re-pinned for content rendered inside it); and
      two guided-workflow "active" buttons lost contrast once composited
      over an already-tinted `--info-soft` card
- [x] Reduced-motion review: verified live with `prefers-reduced-motion:
      reduce` emulated - `styles/motion.css`'s universal `*`/`!important`
      reset neutralizes every CSS animation and transition in the app (no
      competing `!important` animation/transition rule exists anywhere
      else in the codebase, so it cannot be overridden regardless of
      cascade order), and the one JS-driven smooth-scroll call
      (`Unbound.tsx`'s `jumpToSection`) already branches on the same media
      query
- [x] Security policy and private vulnerability-reporting instructions

Later: multiple roles and external identity providers unless real 1.0 demand
requires them.

Exit: documented security review has no unresolved critical or high finding.

---

## 0.6 — beta release engineering

Goal: releases are immutable, traceable, upgradeable, and easy to evaluate.

- [x] Automated semantic versioning across all component repositories -
      RootGuard now follows Conventional Commits
      (`CONTRIBUTING.md`); a manually-triggered `release-version-bump.yml`
      computes the next `v0.1.0-alpha.N` tag, refuses an empty release, and
      pushes it. A human still decides *when* to cut a release, this only
      removes having to type or remember the next version number. Verified
      live end to end (`v0.1.0-alpha.9`) - version computation, changelog
      generation, and the GitHub Release all worked correctly on the first
      run. That same run also surfaced a real bug now fixed: GitHub Actions
      never auto-triggers another workflow's `on: push: tags` from a push
      made with the built-in `GITHUB_TOKEN` (an anti-recursion safeguard),
      so the pushed tag alone never started `release-alpha.yml` - a
      "phantom" release existed (tag + GitHub Release, no images) until the
      fix explicitly dispatches `release-alpha.yml` with the computed
      version, the same `workflow_dispatch` path already used by hand for
      every prior alpha release
      ([rootguard#233](https://github.com/foxly-it/rootguard/issues/233))
- [x] Multi-architecture GHCR manifest lists pinned by digest -
      `release-alpha.yml` publishes proper `linux/amd64,linux/arm64`
      manifest lists for all 5 components, and `compose.alpha.yaml`
      references every one of them, including third-party AdGuard, by
      digest. The `update-alpha-pins` job (added in #165) now updates those
      pins automatically after every release instead of by hand - proven in
      production for the first time with the `v0.1.0-alpha.7` release: all
      four RootGuard image pins landed correctly in a `[skip ci]` commit
      pushed straight to `main`, verified against the actual published
      digests before the tag was cut
- [x] SBOM and provenance for every image -
      `release-alpha.yml`'s shared `docker/build-push-action` step now sets
      `sbom: true` and `provenance: mode=max` for all 5 matrix components
      (previously only `ci-unbound.yml` did, for unbound alone, outside the
      actual release path). Verified against the real `v0.1.0-alpha.8`
      release: every one of the 5 published images carries a fetchable SPDX
      SBOM and SLSA provenance predicate
      (`docker buildx imagetools inspect --format '{{json .SBOM}}'`/
      `'{{json .Provenance}}'`)
      ([rootguard#229](https://github.com/foxly-it/rootguard/issues/229))
- [x] Image signing and signature verification in the release/update path -
      the signing half already existed: `actions/attest@v4` produces a
      GitHub/Sigstore-backed keyless build-provenance attestation
      (Fulcio-signed over GitHub Actions OIDC) for every published image.
      The gap was verification coverage: `attestation.go`'s policy map only
      ever checked `core`/`webapp`, so `updater`/`unbound`/`blockpage`
      silently reported `not_applicable` in the Stack Center despite being
      published by the identical signer. Extended to all 5. Verified
      against the real `v0.1.0-alpha.8` release: `cosign verify-attestation`
      (run from inside a live `rootguard-core` container, the exact binary
      and policy Core itself uses) reports `verified` for all 5 images
      ([rootguard#230](https://github.com/foxly-it/rootguard/issues/230))
- [x] Compatibility matrix for RootGuard, Docker, AdGuard, and Unbound versions -
      implemented (`docs/compatibility-matrix.md` consolidates all four
      axes: three already had real, independent CI proof - Docker
      platform/engine via `docs/platform-support.md`'s clean-install
      matrix, AdGuard channel via `ci-adguard-compat.yml`'s stable/beta
      matrix, Unbound via `ci-unbound.yml`'s native-arch and scenario
      checks - RootGuard pins its own Unbound build, not an operator
      choice. Its fourth axis (the `upgrade-test` job) is now live-verified
      passing against `v0.1.0-alpha.16`, see below
      ([rootguard#243](https://github.com/foxly-it/rootguard/issues/243))
- [x] Upgrade tests from every supported previous RootGuard release -
      scoped to the one release directly before the current one (N-1 -> N):
      RootGuard is pre-1.0 alpha and doesn't yet promise compatibility
      further back. `release-alpha.yml`'s new `upgrade-test` job deploys
      the previous release exactly as it shipped, completes guided setup,
      verifies DNS, then upgrades Core/WebApp in place through the real
      control-plane updater (never a synthetic fixture) to the version
      being published, and verifies the running images and DNS resolution
      afterward. Building it surfaced a real, independent bug now fixed in
      the same change: the release tag is created *before* the pin-update
      commit that follows it, so every existing tag
      (confirmed live: `v0.1.0-alpha.7`, `v0.1.0-alpha.8`) has always
      pointed at a `compose.alpha.yaml` still pinned to the *previous*
      release's images - the documented quick start
      (`curl .../v0.1.0-alpha.N/compose.alpha.yaml`) fetched the wrong
      images for every past release. `update-alpha-pins` now moves the tag
      to the pin-update commit it just created; the `upgrade-test` job
      itself doesn't depend on that fix, since it locates the correct
      pin-update commit directly rather than trusting the tag. Diagnosing
      the job's first live runs took six rounds of fixes to the job itself
      (silently swallowed curl errors, a check/install race, and a
      poll loop that didn't tolerate the WebApp's brief restart mid-swap),
      the last of which surfaced a genuine, independent, previously
      untested product bug: Core's `controlplane.Client` had no `History`
      field, so it silently dropped the updater's update-outcome history
      while proxying its status - fixed with a matching type and a new
      regression test. Live-verified passing end to end against
      `v0.1.0-alpha.16`
      ([rootguard#242](https://github.com/foxly-it/rootguard/issues/242))
- [x] Migration framework for persistent state and configuration schemas -
      scoped deliberately, per explicit user direction, to schema-version +
      fail-closed consistency rather than a full transform-function
      framework: RootGuard doesn't yet have a real breaking-schema-change
      history that would justify one. Every persisted JSON file across
      `rootguard-core`/`rootguard-updater` was audited; two genuine gaps
      were found and closed (the rest already had adequate cheap validity
      checks and low-consequence failure modes, where adding versioning
      would have been ceremony, not a real improvement). The updater's
      per-backup `manifest.json` - which authorizes restoring files into a
      live container during rollback - now carries a `schema_version`,
      matching its sibling `backupexport.Manifest` which already had one.
      Unbound's `settings.json` gets a `schema_version` envelope kept
      deliberately off the `Settings` type itself (also the guided-settings
      HTTP API response shape); `Load` refuses only a *newer* version than
      the build knows, while an absent or older version still flows through
      the existing hand-rolled additive-field migration
      (`jsonFieldExists`) unchanged
      ([rootguard#237](https://github.com/foxly-it/rootguard/issues/237))
- [x] GitHub issue templates for bugs, installations, security, and features -
      `.github/ISSUE_TEMPLATE/bug_report.yml` (its "Betroffene Komponente"
      dropdown includes "Compose / Installation" as a category, covering
      installation issues within the bug template rather than a separate
      one) and `feature_request.yml` cover bugs/installations/features;
      `config.yml` routes security reports to GitHub's private security
      advisories instead of a public template, which is the correct handling
      for vulnerabilities, not a gap
- [x] Public changelog generated from reviewed release entries -
      `cliff.toml` (git-cliff) generates each release's `CHANGELOG.md`
      section and GitHub Release body from Conventional Commit history
      since the last tag, grouped by type and linking `(#123)` references
      to their PR. `CHANGELOG.md` is seeded with the full history across
      all 8 pre-existing alpha releases, scoped to the real
      `v0.1.0-alpha.N` tag pattern rather than a bare `v*` glob - the
      repository also carries a few stray, non-release tags (`v1.0.0`,
      `v0.1.0`, `v0.2.0-service-discovery`) that would otherwise interleave
      into the changelog out of chronological order. Verified live:
      `v0.1.0-alpha.9`'s changelog section and GitHub Release were both
      generated correctly
      ([rootguard#234](https://github.com/foxly-it/rootguard/issues/234))
- [x] Website status and Wiki updated as a required CI/release check -
      scoped, per explicit user direction, to hard verifiable facts rather
      than content review: `scripts/check-site-facts.sh` compares every
      `0.1.0-alpha.N` mention in `site/*.html` against the latest real
      release tag (excluding deliberate historical references like
      "Starting with 0.1.0-alpha.2, ..."), and verifies every local
      `href`/`src` resolves to a real file. Runs on every push/PR to `main`
      via `ci-site-facts.yml`, not path-filtered to `site/**` - the site can
      go stale purely because a release was cut elsewhere with no
      `site/*.html` edit to trigger a path-filtered check. Building this
      surfaced real drift, fixed in the same change: `site/*.html` still
      said `0.1.0-alpha.7` in the status badge, quick-start `curl` commands,
      pinned `.env` image digests, and version labels, even though
      `v0.1.0-alpha.8`/`v0.1.0-alpha.9` had already shipped
      ([rootguard#240](https://github.com/foxly-it/rootguard/issues/240))

Exit: publish `0.1.0-beta.1` for broader self-hosted testing - continuing
the existing `0.1.0-alpha.N` version series, not jumping to `0.6.0`; "0.6"
here is this roadmap's own milestone label, not the software's version
number.

---

## 0.9 — release candidate

Goal: freeze features and prove reliability.

- [x] No unresolved release-blocking defect - final `gh issue list` sweep
      before cutting `1.0.0-rc.1` (2026-08-26): 4 open issues, none
      release-blocking -
      [#328](https://github.com/foxly-it/rootguard/issues/328) (narrow
      updater self-resync edge case),
      [#271](https://github.com/foxly-it/rootguard/issues/271) (the
      30-day endurance test's own tracking issue, see below),
      [#186](https://github.com/foxly-it/rootguard/issues/186) (post-1.0
      extension architecture, explicitly out of scope until after 1.0),
      [#131](https://github.com/foxly-it/rootguard/issues/131) (an Unbound
      scope decision that doesn't block anything shipped so far).
- [x] Fresh install, upgrade, rollback, backup, and restore matrix is green -
      fresh install (`clean-install.yml`), upgrade
      (`release-alpha.yml`'s `upgrade-test`), and rollback
      (`ci-core.yml`/`ci-updater.yml`'s rollback-integration jobs) already
      had real CI coverage against live containers; backup/restore only had
      unit tests against a mocked `docker` command runner until now. Added
      `scripts/verify-backup-restore.sh` + `backup-restore.yml`, mirroring
      `verify-clean-install.sh`'s own conventions, running a real
      export → teardown → fresh install → restore → verify-DNS cycle on
      `amd64`/`arm64` GitHub-hosted runners
      ([rootguard#276](https://github.com/foxly-it/rootguard/pull/276)).
- [x] Performance and memory baselines for small and medium networks -
      `docs/performance-baseline.md`. Small network: passive `docker
      stats` sampling from the endurance test's own probe (100% pass rate
      at write time). Medium network (defined here as 20 sustained QPS,
      since no prior definition existed): a live `dnsperf` run against the
      running instance, 95.9% completion, sub-millisecond average latency,
      no leak signature
      ([rootguard#277](https://github.com/foxly-it/rootguard/pull/277)).
- [x] Final accessibility and security review - `docs/
      accessibility-security-review.md`. `@axe-core/playwright` against a
      real `v0.1.0-beta.1` instance, all 11 routes (including the three
      Unbound sub-sections, specifically re-checked since that's where the
      0.5 audit's real bugs were) x both themes = 22 scans, 0 violations.
      Security: every mutating route cross-referenced against
      `guardDestructive` coverage, a common-anti-pattern sweep (TLS bypass,
      shell-interpolated `exec`, `dangerouslySetInnerHTML`, `eval`,
      hardcoded credentials - all clean), and a threat-model currency
      check. Builds on this cycle's own auth hardening: rate limiting no
      longer trusts `X-Forwarded-For` (bypass + unbounded memory growth),
      password recovery writes are now failure-safe (sessions-then-
      credentials ordering with rollback on either write failing), logout
      surfaces persistence errors like session revocation already did, and
      the static-file guard no longer misinterprets `[ ] * ?` as glob
      patterns
      ([rootguard#274](https://github.com/foxly-it/rootguard/pull/274),
      [#279](https://github.com/foxly-it/rootguard/pull/279)).
- [x] Documentation tested by a user without development context - a
      fresh read-through of the public `docs.html` manual (Requirements
      through Operations) surfaced three real issues: a leftover "Gestoppte
      Alpha wieder starten" from the alpha→beta sweep that grep missed
      because it's inside a `<pre><code>` block rather than a
      `data-de`/`data-en` string, a `dig @ROOTGUARD_LAN_IP` example that
      read as a real shell variable nobody ever told the reader to set, and
      no CPU/RAM guidance in the Requirements checklist. All three fixed
      ([rootguard#281](https://github.com/foxly-it/rootguard/pull/281)).
- [x] Supported platforms, limitations, and support policy frozen -
      `docs/platform-support.md` extended with explicit supported-platform
      statements, minimum requirements grounded in the real
      performance-baseline numbers (2 vCPU/2 GB as the practical floor,
      not a guess), known limitations cross-referencing the docs that
      already cover each in depth, and a pre-1.0 support policy that's
      explicitly deferred to a real support window once 1.0 ships
      ([rootguard#280](https://github.com/foxly-it/rootguard/pull/280)).
- [x] Versioned 1.0 migration and rollback instructions complete -
      `docs/upgrading-to-1.0.md`. Covers the actual, asymmetric upgrade/
      rollback behavior found while writing it: Core/WebApp's update API
      explicitly refuses a downgrade by design (no "click to roll back"
      once an update has succeeded), while AdGuard/Unbound/Blockpage have
      no such check but also no per-request override, and a backup's
      restorability across versions depends on its schema version
      matching exactly, not a release-number comparison
      ([rootguard#539](https://github.com/foxly-it/rootguard/issues/539)).

Exit: publish `1.0.0-rc.1`; only bug fixes and documentation may follow.

**Exited 2026-08-26**: [`v1.0.0-rc.1`](https://github.com/foxly-it/rootguard/releases/tag/v1.0.0-rc.1)
published - images built and pushed for all five components, smoke-test
and upgrade-test (a real `0.1.0-beta.14` install upgraded in place through
the control-plane updater) both green, site pinned to the release. From
here on: only bug fixes and documentation, per the exit criterion above,
until the two remaining 1.0.0-gating items close (the 30-day soak test and
the migration/rollback docs it and the rest of 1.0.0's scope gate, see
below).

**`v1.0.0-rc.2` (2026-09-02)**, a bug-fix release within this same phase,
not a new milestone: cutting `1.0.0-rc.2` exposed that Core/Updater's
Cosign attestation checks (gating every image activation since `rc.1`)
had never actually been exercised against the real, internet-isolated
`control` network before. Fixed with a sixth RootGuard component,
`rootguard-attestation-proxy` - a minimal, hardcoded-3-host-allowlist
CONNECT proxy sitting on a new `egress` network, so `control` stays fully
isolated while cosign gets exactly the narrow path it needs. Full
narrative: `.worklog.md`'s 2026-09-01/02 entry and
`docs/security-audit-log.md`. Follow-up filed, explicitly scoped for
before `rc.3`, not before `1.0.0`: bring the proxy itself into self-update
management - **done** (2026-09-03): a fully separate, parallel update
channel, own routes/UI/audit action, cosign-verified through the
identical policy as every other component
([rootguard#481](https://github.com/foxly-it/rootguard/issues/481),
closed).

**2026-09-05, internal review round (no external tooling):** the user
asked for a careful internal security/quality pass rather than waiting
for another third-party audit. 10 Medium/Low findings across rate
limiting, an Unbound open-resolver footgun, attestation-check
robustness, a compose self-referential-mount bug, and a control-network
isolation gap - no Critical/High, consistent with round 1's own
"ungewöhnlich sauber" verdict. All fixed same-day, one issue/PR each.
Also caught and fixed along the way: Blockpage CI had been failing on
every push (an Alpine package-pin drift, unrelated to the review
itself) blocking merges via branch protection. Full narrative:
`docs/security-audit-log.md`'s "Internal review, no external tooling"
entry.

**`v1.0.0-rc.3` (2026-09-05) was published broken and pulled the same
day; `v1.0.0-rc.4` fixes it.** One of the review round's own fixes above
(the attestation image-prefix anchor) was tested against only one of
two legitimate production reference shapes, breaking every fresh
guided-setup install of rc.3. Found live within minutes of publishing,
fixed same-day, `v1.0.0-rc.3`'s tag/release deleted (its own git commit
stays in history) since `upgrade-test` resolves its "previous release"
baseline from git tags and would otherwise fail on every future cut.
`v1.0.0-rc.4` is the current release, fully green including a live
fresh-install re-verification. Full incident narrative:
`docs/release-history.md`'s "`v1.0.0-rc.3`: published broken, pulled
same day" entry.

---

## 1.0.0 — stable Docker appliance

RootGuard 1.0 ships when:

- [x] thirty-day continuous DNS test with update and restore exercises
      passes clean, run against the published `1.0.0-rc.1` candidate itself
      rather than gating the RC's own existence - a longer-running
      endurance test is exactly what an RC period is for, and this specific
      test exercises the update/backup/restore machinery generically, not
      any one build, so it doesn't need to precede the RC to be meaningful.
      Ran 2026-08-14 through 2026-09-14 on a dedicated host
      (`scripts/soak/*.sh` + systemd timers, see that directory's README).
      Final `report.sh` rollup: 4175 probes, 95% cumulative pass rate - all
      197 failures are the identical, already-classified transient
      DNSSEC-timeout jitter (`dnssec_reject_ok:false`, resolve/block/api
      always `true`), confirmed not a RootGuard defect and no new failure
      class across the full run; 10 update exercises (4 `success`, 6
      `no_change`), zero failures; 5 backup/restore drills, all clean
      restores, zero fallbacks used, one post-restore DNS-check failure
      (2026-08-21) matching the already-tracked
      [#443](https://github.com/foxly-it/rootguard/issues/443) flake, not a
      new cause. Gate passes clean
      ([rootguard#271](https://github.com/foxly-it/rootguard/issues/271)).
- [x] installation, DNS operation, configuration, upgrades, and restore are
      repeatable on every supported platform - clean install, upgrade
      (`upgrade-test`), and backup/restore all now run on the identical
      native `amd64`/`arm64` matrix; `upgrade-test` was `amd64`-only until
      found and fixed while closing this item out
      ([rootguard#539](https://github.com/foxly-it/rootguard/issues/539)).
- [x] no supported change can bypass validation and recovery - every one
      of the WebApp's 57 registered routes individually re-verified: each
      mutating route carries the shared destructive-action guard (or the
      tighter restore-specific one), every unguarded route confirmed
      read-only, a dry-run/preview, or a bounded, non-user-sized check
      ([rootguard#539](https://github.com/foxly-it/rootguard/issues/539)).
- [x] immutable signed artifacts and complete source are published - SLSA
      provenance (`mode=max`) and an SBOM ship for all six images via
      `actions/attest`, verified by Core/the Updater's own Cosign policy
      before activation; the repository is public, AGPL-3.0
      ([rootguard#539](https://github.com/foxly-it/rootguard/issues/539)).
- [x] the documentation matches the shipped UI and configuration model -
      targeted currency pass against today's changes found and fixed a
      real gap: `docs/threat-model.de.md` had never received the Finding-1
      fix earlier this round, still describing a since-fixed gap as open
      ([rootguard#539](https://github.com/foxly-it/rootguard/issues/539)).
- [x] security, accessibility, backup, and release gates above are
      complete - the `0.9` checklist above is now fully checked.
- [x] a tested rollback path from 1.0 to the previous stable state is
      documented - `docs/upgrading-to-1.0.md`.

Post-1.0 candidates: bare-metal/systemd provider, multi-node management, high
availability, and external identity providers.

## How we work with this roadmap

For each development slice:

1. Select one unchecked item and assign a stable issue ID.
2. Write acceptance tests before or with the implementation.
3. Update code, API, WebGUI translations, documentation, and project state together.
4. Record verification and mark the checkbox only after it passes.
5. Do not start the next release phase while an earlier safety gate remains
   unresolved unless the work is independent and explicitly tracked.
6. Add a new "Post-1.0 / Future" item at the *top* of that section's list,
   not appended at the bottom - it stays ordered newest first so the most
   recent work is visible without scrolling past the historical record
   below. When a new item directly follows up on an existing one (a
   deferred piece of the same change), keep it adjacent to that item
   rather than strictly by date, and describe the relationship without
   relying on "above"/"below" - the list gets reordered as new work lands,
   which quietly breaks positional wording like that.