# Debian 13 historical migration from v4.02.8 to v4.03.2

> Status: Version-specific
> Documentation baseline: v4.03.2
> Published at: 2026-08-25T14:15:49Z

**Historical route:** This page is retained for the exact Debian and package
versions in its title. It is not a general path to current source. Do not
replace package versions, expected hashes or migration checks with newer
values without a separately reviewed migration plan.

This runbook covers the one-time migration of an existing Debian 13 host from
the historical public SysWarden v4.02.8 package to v4.03.2.
The historical v4.02.8 binary predates the signed updater protocol. The first
hop to v4.03.2 must use a separately downloaded and checksum-verified Linux
package. This procedure also requires provenance verification of the selected
DEB.

This procedure does not authorize a production change by itself. Complete it
first on a restored snapshot or disposable clone that matches the target. Keep
provider console access and a second verified SSH session throughout the live
change.

Keep verified local console or SSH recovery access throughout the migration.

> [!IMPORTANT]
> **TARGET VALIDATION REQUIRED:** No command in this page is claimed to have
> run successfully on the intended VPS until its output is captured in the
> migration evidence. Replace no placeholder with an inferred value.

## 1. Scope and safety boundary

The supported source and destination for this page are exact:

| Property | Required value |
|---|---|
| Source operating system | Debian 13 |
| Source package | `syswarden` version `4.02.8` |
| Destination package | `syswarden` version `4.03.2` |
| Destination architecture | `amd64` |
| Service manager | Active systemd on the live VPS |
| Recovery method | Provider or hypervisor snapshot, with console access |

The v4.03.2 package migration is not a generic host rollback facility.
`syswarden uninstall` is destructive removal and must never be used as an
upgrade rollback. Restoring the preferred pre-upgrade snapshot is a separate,
whole-host recovery action. It can revert unrelated workload data and therefore
requires an approved consistency and reconciliation plan.

SysWarden is a host firewall orchestrator and out-of-band security-log analysis
toolkit. WAAP reads logs after an application writes them. It is not an inline
HTTP proxy or a replacement for an inline WAF.

## 2. Mandatory stop conditions

Stop before installing the candidate if any condition below is true:

1. Provider console access is unavailable, or a second independent SSH session
   cannot be kept open.
2. A complete pre-upgrade snapshot has not been created and its restoration has
   not been tested on a clone. Stop as well when the snapshot includes mutable
   application data without an approved quiescence, external durability,
   replay or reconciliation plan for the possible whole-host restore.
   The protected file backup created in section 4 must also have a verified
   encrypted off-host copy because it is created after the snapshot.
3. `/etc/os-release` does not identify Debian version 13.
4. `dpkg-query` does not report `syswarden` as `install ok installed`, version
   `4.02.8`, or `dpkg --print-architecture` is not exactly `amd64`.
5. `dpkg --audit` reports an unfinished transaction before the change.
6. An `apt`, `apt-get`, `dpkg` or unattended-upgrade process is already active.
   Never delete package-manager lock files.
7. The public v4.03.2 Release is missing, draft, prerelease, or has an asset
   inventory different from the exact ten-file inventory in this page.
8. The remote `v4.03.2` tag, provenance attestation, approved SHA-256 value,
   release checksum record and local package digest do not all agree.
9. DEB package name, version, architecture, dependencies or control-script
   inventory differs from this page.
10. A required path is a symlink, has unexpected ownership, or represents a
    custom installation that cannot be attributed to the historical package.
11. `/etc/syswarden/config/.migration-in-progress` exists before the planned
    dry run. It represents an earlier incomplete migration and must be handled
    as recovery, not as a fresh upgrade.
12. Either `/opt/syswarden/syswarden-auto.conf.migration_backup` or
    `/opt/syswarden/syswarden-auto.conf.migration_backup.migrated` exists. These
    are interrupted-transaction states and must be handled as recovery, not as
    a fresh upgrade.
13. Both a legacy source that still needs migration and
    `/opt/syswarden/syswarden-auto.conf.bak` exist. The package correctly
    refuses to overwrite the existing archive.
14. The candidate migration dry run fails, reports an unsupported key, or
    produces a firewall, WireGuard, SSH, HA or port choice that has not been
    reviewed.
15. WireGuard is enabled while the migrated firewall backend is not
    `nftables`.
16. Firewall state is ambiguous, an iptables service is active or enabled, or
    the selected backend is incompatible with the active frontend.
17. The effective SSH port and current administrative source address have not
    been captured from the live SSH transport. Stop as well unless that source
    is present in the persistent absolute whitelist and, for a SysWarden-owned
    nftables policy, in the effective kernel set before the snapshot is taken.
18. The package simulation proposes a removal, downgrade, architecture change
    or unrelated package change.

Do not work around a fail-closed error by deleting a marker, moving a backup,
replacing a symlink, disabling validation, or adding `|| true`.

## 3. Capture read-only before-state evidence

> [!IMPORTANT]
> **TARGET VALIDATION REQUIRED:** Run these observations on the target before
> the snapshot. Store their output in a root-only evidence location. Do not
> paste configuration contents, HA tokens, webhook URLs, SSH keys or other
> secrets into an issue or public log.

Record the platform and installed package:

```bash
set -euo pipefail
date --iso-8601=seconds
uname -a
cat /etc/os-release
dpkg --print-architecture
dpkg-query --show --showformat='${Status}\n${Version}\n${Architecture}\n' syswarden
DPKG_AUDIT="$(sudo dpkg --audit)"
if [[ -n "${DPKG_AUDIT}" ]]; then
  printf '%s\n' "${DPKG_AUDIT}" >&2
  printf '%s\n' 'dpkg reports unfinished or inconsistent package state.' >&2
  exit 1
fi
sudo /opt/syswarden/bin/syswarden-cli
```

The required package output is `install ok installed`, `4.02.8`, and `amd64`.
The output of `dpkg --print-architecture` must also be exactly `amd64`.

Record service, listener, firewall and SSH state without changing it:

```bash
observe_status() {
  printf '\nObservation:'
  printf ' %q' "$@"
  printf '\n'
  if "$@"; then
    OBSERVATION_STATUS=0
  else
    OBSERVATION_STATUS=$?
  fi
  printf 'Observation exit status: %d\n' "${OBSERVATION_STATUS}"
}

observe_status sudo systemctl show \
  syswarden-core.service syswarden-firewall.service \
  --property=Id,LoadState,ActiveState,SubState,UnitFileState,FragmentPath,DropInPaths
observe_status sudo systemctl show ssh.service \
  --property=Id,LoadState,ActiveState,SubState,UnitFileState,FragmentPath,DropInPaths
observe_status sudo systemctl show \
  nftables.service firewalld.service ufw.service iptables.service \
  ip6tables.service netfilter-persistent.service \
  --property=Id,LoadState,ActiveState,SubState,UnitFileState,FragmentPath,DropInPaths
sudo ss -lntupH
sudo nft --handle list ruleset
sudo sshd -t
sudo sshd -T
```

Capture the live administrative transport before opening a login root shell.
The four `SSH_CONNECTION` fields are the client address and port followed by
the server address and port. Do not infer these values from DNS or shell
history:

```bash
set -euo pipefail
export LC_ALL=C
test -n "${SSH_CONNECTION:-}"
read -r ADMIN_SOURCE ADMIN_SOURCE_PORT SSH_DESTINATION SSH_SESSION_PORT SSH_EXTRA \
  <<< "${SSH_CONNECTION}"
test -n "${ADMIN_SOURCE}" && test -n "${SSH_DESTINATION}"
test -z "${SSH_EXTRA:-}"
case "${ADMIN_SOURCE_PORT}" in ''|*[!0-9]*) exit 1 ;; esac
case "${SSH_SESSION_PORT}" in ''|*[!0-9]*) exit 1 ;; esac
test "${ADMIN_SOURCE_PORT}" -ge 1 && test "${ADMIN_SOURCE_PORT}" -le 65535
test "${SSH_SESSION_PORT}" -ge 1 && test "${SSH_SESSION_PORT}" -le 65535
SSHD_EFFECTIVE="$(sudo sshd -T)"
mapfile -t EFFECTIVE_SSH_PORTS < <(
  awk '$1 == "port" { print $2 }' <<< "${SSHD_EFFECTIVE}"
)
if (( ${#EFFECTIVE_SSH_PORTS[@]} != 1 )) ||
   [[ "${EFFECTIVE_SSH_PORTS[0]:-}" != "${SSH_SESSION_PORT}" ]]; then
  printf '%s\n' \
    'The server must expose exactly one effective SSH port matching the live session.' >&2
  exit 1
fi
sudo ss -ltnH "sport = :${SSH_SESSION_PORT}" | grep -q .
printf 'Administrative source: %s\nEffective SSH port: %s\n' \
  "${ADMIN_SOURCE}" "${SSH_SESSION_PORT}"
```

Keep the address in protected change evidence. It can identify an operator or
network and must not be copied into a public issue.

An inactive or absent frontend command can return nonzero. Record each result
individually. Accept a nonzero result only when the output explicitly identifies
an inactive, disabled or absent unit. A privilege, D-Bus or `systemctl`
execution error is a stop condition. Do not join these observations with a
fallback that hides the first result.

Record file identities and hashes without printing secrets:

```bash
for path in \
  /opt/syswarden \
  /opt/syswarden/syswarden-auto.conf \
  /etc/syswarden \
  /var/lib/syswarden \
  /etc/systemd/system/syswarden-core.service \
  /etc/systemd/system/syswarden-firewall.service; do
  if sudo test -e "${path}" || sudo test -L "${path}"; then
    sudo stat --format='%n %F %U:%G %a %d:%i %s' "${path}"
  else
    printf 'ABSENT: %s\n' "${path}"
  fi
done
sudo sha256sum \
  /opt/syswarden/bin/syswarden-cli \
  /opt/syswarden/bin/syswarden-core \
  /opt/syswarden/bin/syswarden-tui
sudo find /etc/syswarden -xdev -type f -exec sha256sum -- '{}' +
CRONTAB_HASH_FILE="$(mktemp /tmp/syswarden-root-crontab-hash.XXXXXXXX)"
chmod 0600 "${CRONTAB_HASH_FILE}"
if sudo crontab -l | sha256sum > "${CRONTAB_HASH_FILE}"; then
  cat "${CRONTAB_HASH_FILE}"
else
  CRONTAB_PIPE_STATUS=("${PIPESTATUS[@]}")
  if (( CRONTAB_PIPE_STATUS[0] == 1 && CRONTAB_PIPE_STATUS[1] == 0 )); then
    printf '%s\n' 'ABSENT: no root crontab is installed.'
  else
    printf '%s\n' 'Failed to hash the root crontab.' >&2
    rm -f -- "${CRONTAB_HASH_FILE}"
    exit 1
  fi
fi
rm -f -- "${CRONTAB_HASH_FILE}"
```

If an optional path is absent, record that fact. If a required directory or
legacy configuration path is a symlink, stop.

Also capture, in protected evidence:

- `journalctl --boot` output for `syswarden-core`, `syswarden-firewall`, `ssh`,
  `rsyslog` and the active firewall frontend;
- current CPU and memory observations for both SysWarden units;
- the current root crontab and `/etc/cron.d/syswarden`, if present;
- hashes and metadata for HA certificates, WireGuard configuration, persistent
  lists and rsyslog bridge files;
- the identity of any process using TCP 62027. SysWarden v4.03.2 must not own a
  listener or generated firewall permission on that port.

## 4. Create a protected backup and snapshot

Before taking the snapshot, make the current administrative source durable in
the historical v4.02.8 absolute whitelist. Exercise this exact mutation on the
restored clone first. Run it from the live SSH session, not from a detached root
shell:

```bash
set -euo pipefail
export LC_ALL=C
test -n "${SSH_CONNECTION:-}"
read -r ADMIN_SOURCE ADMIN_SOURCE_PORT SSH_DESTINATION SSH_SESSION_PORT SSH_EXTRA \
  <<< "${SSH_CONNECTION}"
test -n "${ADMIN_SOURCE}" && test -z "${SSH_EXTRA:-}"
case "${SSH_SESSION_PORT}" in ''|*[!0-9]*) exit 1 ;; esac

case "${ADMIN_SOURCE}" in
  *:*)
    ADMIN_LIST=/etc/syswarden/lists/syswarden_whitelist.ipv6
    ADMIN_BLOCKLIST=/etc/syswarden/lists/syswarden_blacklist.ipv6
    ADMIN_SET=syswarden_whitelist6
    ;;
  *)
    ADMIN_LIST=/etc/syswarden/lists/syswarden_whitelist.ipv4
    ADMIN_BLOCKLIST=/etc/syswarden/lists/syswarden_blacklist.ipv4
    ADMIN_SET=syswarden_whitelist
    ;;
esac

assert_safe_legacy_directory() {
  local path="$1" identity uid gid mode links
  if sudo test -L "${path}"; then
    printf 'Refusing symlink directory: %s\n' "${path}" >&2
    return 1
  fi
  identity="$(sudo stat --format='%u|%g|%a|%h' -- "${path}")"
  IFS='|' read -r uid gid mode links <<< "${identity}"
  if ! sudo test -d "${path}" || [[ "${uid}" != 0 || "${gid}" != 0 ]] ||
     (( (8#${mode} & 8#022) != 0 )); then
    printf 'Unsafe legacy directory identity: %s %s\n' \
      "${path}" "${identity}" >&2
    return 1
  fi
}

assert_safe_legacy_file() {
  local path="$1" identity uid gid mode links
  if sudo test -L "${path}"; then
    printf 'Refusing symlink file: %s\n' "${path}" >&2
    return 1
  fi
  if ! sudo test -e "${path}"; then
    return 0
  fi
  identity="$(sudo stat --format='%u|%g|%a|%h' -- "${path}")"
  IFS='|' read -r uid gid mode links <<< "${identity}"
  if ! sudo test -f "${path}" || [[ "${uid}" != 0 || "${gid}" != 0 ||
        "${links}" != 1 ]] || (( (8#${mode} & 8#022) != 0 )); then
    printf 'Unsafe legacy file identity: %s %s\n' \
      "${path}" "${identity}" >&2
    return 1
  fi
}

assert_safe_legacy_directory /etc/syswarden
assert_safe_legacy_directory /etc/syswarden/lists
sudo find -P /etc/syswarden/lists -xdev -mindepth 1 -maxdepth 1 -print0 |
  while IFS= read -r -d '' LEGACY_LIST_PATH; do
    assert_safe_legacy_file "${LEGACY_LIST_PATH}"
  done
assert_safe_legacy_file "${ADMIN_LIST}"
assert_safe_legacy_file "${ADMIN_BLOCKLIST}"
assert_safe_legacy_file /etc/syswarden/syswarden.nft

if sudo nft list table inet syswarden >/dev/null 2>&1 &&
   sudo nft list table netdev syswarden_hw_drop >/dev/null 2>&1; then
  if ! sudo grep -Fxq -- "${ADMIN_SOURCE}" "${ADMIN_LIST}"; then
    sudo /opt/syswarden/bin/syswarden-cli whitelist "${ADMIN_SOURCE}"
  fi
  sudo grep -Fx -- "${ADMIN_SOURCE}" "${ADMIN_LIST}"
  sudo nft get element inet syswarden "${ADMIN_SET}" \
    \{ "${ADMIN_SOURCE}" \}
  sudo nft get element netdev syswarden_hw_drop "${ADMIN_SET}" \
    \{ "${ADMIN_SOURCE}" \}
else
  printf '%s\n' \
    'Both SysWarden nftables tables are required for this helper path.' >&2
  printf '%s\n' \
    'Stop here. Use the recovery console to prove an exact source-and-port rule in the retained operator-managed frontend before following its separately approved path.' >&2
  exit 1
fi
```

The safe path, persistent registry check and successful element lookups in both
`inet syswarden` and `netdev syswarden_hw_drop` are mandatory. If either table
is absent or either lookup fails, the block exits nonzero. For a retained
operator-managed frontend, do not run the historical helper. Attach exact
native source-and-port rule evidence from the recovery console and obtain
separate approval for that frontend-specific path.
Do not continue merely because the original established connection still
works: an established flow does not prove that a new connection will survive
policy replacement.

Create a provider or hypervisor snapshot only after this exception is proven,
while the recovery console remains available. Record its immutable identifier,
creation time and restoration test. The restoration test must boot a clone and
prove SSH, package, service and firewall state before the production change
begins.

Record the snapshot scope and application recovery point. Prefer a system-volume
snapshot when mutable application data is stored and protected separately. If
the snapshot includes mutable workload data, quiesce its writers or use the
provider-approved application-consistent mechanism, and retain the external
backup, transaction log or replay plan required to reconcile changes made after
the snapshot. Do not use whole-host restoration as the rollback plan when that
boundary cannot be proved.

Back up `/etc/syswarden`, required list files and HA trust material. The
protected archive below also captures the remaining attributed SysWarden state
needed for forensic comparison.

Create a separate root-only file backup for forensic comparison. This does not
replace the snapshot:

```bash
set -euo pipefail
BACKUP_ROOT="$(sudo mktemp -d /root/syswarden-v4028-backup.XXXXXXXX)"
sudo chmod 0700 "${BACKUP_ROOT}"

for path in \
  /etc/syswarden \
  /opt/syswarden \
  /var/lib/syswarden \
  /var/log/syswarden \
  /etc/systemd/system/syswarden-core.service \
  /etc/systemd/system/syswarden-firewall.service \
  /etc/systemd/system/multi-user.target.wants/syswarden-core.service \
  /etc/systemd/system/multi-user.target.wants/syswarden-firewall.service \
  /etc/rsyslog.d/99-syswarden-waf-bridge.conf \
  /etc/cron.d/syswarden \
  /etc/ssh \
  /etc/wireguard; do
  if sudo test -e "${path}" || sudo test -L "${path}"; then
    printf '%s\0' "${path#/}"
  fi
done | sudo tee "${BACKUP_ROOT}/paths.nul" >/dev/null

sudo tar --acls --xattrs --numeric-owner \
  --directory=/ --null --files-from="${BACKUP_ROOT}/paths.nul" \
  --create --file="${BACKUP_ROOT}/rootfs.tar"
sudo chmod 0600 "${BACKUP_ROOT}/paths.nul" "${BACKUP_ROOT}/rootfs.tar"
sudo nft --handle list ruleset | sudo tee "${BACKUP_ROOT}/nftables-before.txt" >/dev/null
sudo sshd -T | sudo tee "${BACKUP_ROOT}/sshd-effective-before.txt" >/dev/null
if sudo crontab -l | sudo tee "${BACKUP_ROOT}/root-crontab-before.txt" >/dev/null; then
  :
else
  CRONTAB_PIPE_STATUS=("${PIPESTATUS[@]}")
  if (( CRONTAB_PIPE_STATUS[0] != 1 || CRONTAB_PIPE_STATUS[1] != 0 )); then
    printf '%s\n' 'Failed to capture the root crontab.' >&2
    exit 1
  fi
  printf '%s\n' '# ABSENT: no root crontab was installed before migration.' | \
    sudo tee "${BACKUP_ROOT}/root-crontab-before.txt" >/dev/null
fi
sudo chmod 0600 \
  "${BACKUP_ROOT}/nftables-before.txt" \
  "${BACKUP_ROOT}/sshd-effective-before.txt" \
  "${BACKUP_ROOT}/root-crontab-before.txt"
ROOTFS_SHA256="$(sudo sha256sum "${BACKUP_ROOT}/rootfs.tar" | awk '{print $1}')"
test "$(printf '%s' "${ROOTFS_SHA256}" | grep -Ec '^[0-9a-f]{64}$')" -eq 1
printf '%s  rootfs.tar\n' "${ROOTFS_SHA256}" | \
  sudo tee "${BACKUP_ROOT}/SHA256SUMS" >/dev/null
sudo chmod 0600 "${BACKUP_ROOT}/SHA256SUMS"
printf 'Protected backup: %s\n' "${BACKUP_ROOT}"
```

The backup can contain SSH private keys, HA tokens, WireGuard keys and webhook
credentials. Keep it root-only and never attach it to a public ticket. Before
installation, copy the complete backup directory through an approved encrypted
channel to storage outside the target and outside the rollback snapshot. In the
copied directory, run `sha256sum --check --strict SHA256SUMS`; the manifest uses
the relative name `rootfs.tar` so this verifies the off-host copy rather than
the original target path. Record the protected storage identity and prove it
can be retrieved. Stop if this verified off-host copy is not available.

## 5. Verify release identity, provenance and package digest

Perform this section on a trusted control workstation before transferring the
package to the target. GitHub CLI, Git, `jq` and `sha256sum` are required.

### 5.1 Resolve the exact release tag commit

```bash
set -euo pipefail
REPOSITORY=duggytuxy/syswarden
TAG=v4.03.2
TAG_ROWS="$(git ls-remote --exit-code \
  "https://github.com/${REPOSITORY}.git" \
  "refs/tags/${TAG}" "refs/tags/${TAG}^{}")"
printf '%s\n' "${TAG_ROWS}"

DIRECT_SHA="$(printf '%s\n' "${TAG_ROWS}" | \
  awk -v ref="refs/tags/${TAG}" '$2 == ref {print $1}')"
PEELED_SHA="$(printf '%s\n' "${TAG_ROWS}" | \
  awk -v ref="refs/tags/${TAG}^{}" '$2 == ref {print $1}')"
test "$(printf '%s\n' "${DIRECT_SHA}" | grep -Ec '^[0-9a-f]{40}$')" -eq 1
if test -n "${PEELED_SHA}"; then
  test "$(printf '%s\n' "${PEELED_SHA}" | grep -Ec '^[0-9a-f]{40}$')" -eq 1
fi
RELEASE_SHA="${PEELED_SHA:-${DIRECT_SHA}}"
printf 'Resolved release commit: %s\n' "${RELEASE_SHA}"
```

Record `RELEASE_SHA` in the approved change evidence. Do not accept a shortened
or mixed-case commit ID.

### 5.2 Require the exact signed public Release tag

The v4.03.2 Release is published. Bind the annotated signed tag object to the
exact qualified commit before accepting any package:

```bash
set -euo pipefail
umask 077
: "${REPOSITORY:?Run section 5.1 in this shell first}"
: "${TAG:?Run section 5.1 in this shell first}"
: "${DIRECT_SHA:?Run section 5.1 in this shell first}"
: "${PEELED_SHA:?Run section 5.1 in this shell first}"
: "${RELEASE_SHA:?Run section 5.1 in this shell first}"
EXPECTED_TAG_OBJECT_SHA=f29eea8b05d5a82b851d4ea864f997ae906ba628
EXPECTED_RELEASE_SHA=2eae757bbdee510fdd1058ba7770f2c5564ecb23
test "${DIRECT_SHA}" = "${EXPECTED_TAG_OBJECT_SHA}"
test "${PEELED_SHA}" = "${EXPECTED_RELEASE_SHA}"
test "${RELEASE_SHA}" = "${EXPECTED_RELEASE_SHA}"

SIGNED_TAG_DIR="$(mktemp -d /tmp/syswarden-v4032-tag.XXXXXXXX)"
chmod 0700 "${SIGNED_TAG_DIR}"
TAG_OBJECT_JSON="${SIGNED_TAG_DIR}/tag-object.json"
gh api "repos/${REPOSITORY}/git/tags/${DIRECT_SHA}" > "${TAG_OBJECT_JSON}"
jq -e \
  --arg tag "${TAG}" \
  --arg tag_object "${EXPECTED_TAG_OBJECT_SHA}" \
  --arg release "${EXPECTED_RELEASE_SHA}" '
    (.sha == $tag_object) and
    (.tag == $tag) and
    (.object.type == "commit") and
    (.object.sha == $release) and
    (.verification.verified == true)
  ' "${TAG_OBJECT_JSON}" >/dev/null
```

These object identifiers are immutable qualification inputs. A different tag
object or commit requires a separately versioned and qualified release.

### 5.3 Require the exact public Release and asset inventory

```bash
set -euo pipefail
umask 077
RELEASE_DIR="$(mktemp -d /tmp/syswarden-v4032-release.XXXXXXXX)"
chmod 0700 "${RELEASE_DIR}"
gh release view v4.03.2 --repo duggytuxy/syswarden \
  --json tagName,isDraft,isPrerelease,targetCommitish,databaseId,assets \
  > "${RELEASE_DIR}/release.json"
jq -e '
  .tagName == "v4.03.2" and
  .isDraft == false and
  .isPrerelease == false and
  (.databaseId | type == "number") and
  .databaseId > 0
' \
  "${RELEASE_DIR}/release.json" >/dev/null
jq -r '"Public Release database ID: \(.databaseId)"' \
  "${RELEASE_DIR}/release.json"
jq -r '.assets[].name' "${RELEASE_DIR}/release.json" | LC_ALL=C sort \
  > "${RELEASE_DIR}/actual-assets.txt"
printf '%s\n' \
  RELEASE_SHA256SUMS.txt \
  SHA256SUMS.txt \
  plumber-report.zip \
  syswarden-4.03.2-1.x86_64.rpm \
  syswarden-release.tar.gz \
  syswarden-sbom.spdx.json \
  syswarden-update-manifest-v1.json \
  syswarden-update-manifest-v1.json.sig \
  syswarden_4.03.2_amd64.deb \
  syswarden_4.03.2_x86_64.apk \
  > "${RELEASE_DIR}/expected-assets.txt"
diff --unified \
  "${RELEASE_DIR}/expected-assets.txt" \
  "${RELEASE_DIR}/actual-assets.txt"
```

The fail-closed diff must produce no output. It compares the public Release
against exactly these ten entries, in this order, with no extras or
duplicates:

1. `RELEASE_SHA256SUMS.txt`
2. `SHA256SUMS.txt`
3. `plumber-report.zip`
4. `syswarden-4.03.2-1.x86_64.rpm`
5. `syswarden-release.tar.gz`
6. `syswarden-sbom.spdx.json`
7. `syswarden-update-manifest-v1.json`
8. `syswarden-update-manifest-v1.json.sig`
9. `syswarden_4.03.2_amd64.deb`
10. `syswarden_4.03.2_x86_64.apk`

### 5.4 Download and verify the selected DEB

This runbook supports only the Debian `amd64` package. Bind the package
selection explicitly:

```bash
TARGET_ARCH=amd64
test "${TARGET_ARCH}" = amd64
DEB_NAME="syswarden_4.03.2_${TARGET_ARCH}.deb"

gh release download v4.03.2 --repo duggytuxy/syswarden \
  --dir "${RELEASE_DIR}" \
  --pattern "${DEB_NAME}" \
  --pattern SHA256SUMS.txt \
  --pattern RELEASE_SHA256SUMS.txt \
  --pattern syswarden-update-manifest-v1.json \
  --pattern syswarden-update-manifest-v1.json.sig
cd "${RELEASE_DIR}"
```

The independently approved digest for the published amd64 DEB is pinned below.
Do not replace it with a value calculated from a fresh download:

```bash
EXPECTED_DEB_SHA256=e499370fbed0e40968a6377f4e3cd9a8718993352deccc181a4fb43333289019
test "$(printf '%s' "${EXPECTED_DEB_SHA256}" | grep -Ec '^[0-9a-f]{64}$')" -eq 1

MANIFEST_SHA256="$(awk -v name="${DEB_NAME}" \
  '$2 == name {count++; digest=$1} END {if (count != 1) exit 2; print digest}' \
  SHA256SUMS.txt)"
test "$(printf '%s' "${MANIFEST_SHA256}" | grep -Ec '^[0-9a-f]{64}$')" -eq 1
test "${MANIFEST_SHA256}" = "${EXPECTED_DEB_SHA256}"
printf '%s  %s\n' "${EXPECTED_DEB_SHA256}" "${DEB_NAME}" | \
  sha256sum --check --strict -

RELEASE_MANIFEST_SHA256="$(awk -v name="${DEB_NAME}" \
  '$2 == name {count++; digest=$1} END {if (count != 1) exit 2; print digest}' \
  RELEASE_SHA256SUMS.txt)"
test "${RELEASE_MANIFEST_SHA256}" = "${EXPECTED_DEB_SHA256}"
```

Verify GitHub build provenance for the exact file, release workflow and source
commit:

```bash
gh attestation verify "${DEB_NAME}" \
  --repo duggytuxy/syswarden \
  --signer-workflow duggytuxy/syswarden/.github/workflows/release-manager.yml \
  --source-digest "${RELEASE_SHA}" \
  --deny-self-hosted-runners
```

The DEB is not claimed to carry a separate embedded `dpkg` signature. The
production authenticity chain is the resolved Release tag commit, GitHub build
provenance, an independently approved SHA-256 value, and the exact Release
manifests. The detached Ed25519 signature authenticates the updater manifest
used by signed updates after this first manual hop.

## 6. Validate exact DEB metadata before installation

Transfer the verified package, both checksum files, the updater manifest and
its signature through an authenticated channel to a new root-only directory on
the target. Recompute the package SHA-256 after transfer and compare it with
`EXPECTED_DEB_SHA256` again.

Create `/root/syswarden-v4032-stage` with mode `0700`, place only the five
verified files there, then open a dedicated trusted root shell with `sudo -i`.
Run the remaining target snippets from that root shell so access to the
root-only staging and evidence directories is unambiguous.

> [!IMPORTANT]
> **TARGET VALIDATION REQUIRED:** The commands below must pass on the actual
> Debian 13 host. Do not install if any assertion fails.

```bash
set -euo pipefail
cd /root/syswarden-v4032-stage
TARGET_ARCH="$(dpkg --print-architecture)"
test "${TARGET_ARCH}" = amd64
DEB_NAME="syswarden_4.03.2_${TARGET_ARCH}.deb"
EXPECTED_DEB_SHA256=e499370fbed0e40968a6377f4e3cd9a8718993352deccc181a4fb43333289019
test "$(printf '%s' "${EXPECTED_DEB_SHA256}" | grep -Ec '^[0-9a-f]{64}$')" -eq 1
printf '%s  %s\n' "${EXPECTED_DEB_SHA256}" "${DEB_NAME}" | \
  sha256sum --check --strict -
test "$(dpkg-deb --field "${DEB_NAME}" Package)" = syswarden
test "$(dpkg-deb --field "${DEB_NAME}" Version)" = 4.03.2
test "$(dpkg-deb --field "${DEB_NAME}" Architecture)" = "${TARGET_ARCH}"
```

The `Depends` field must be exactly:

```text
nftables, ipset, curl, wget, rsyslog, cron, bash-completion, wireguard-tools, qrencode, jq, unattended-upgrades, apt-listchanges, procps, e2fsprogs
```

Assert it before installation:

```bash
EXPECTED_DEPENDS='nftables, ipset, curl, wget, rsyslog, cron, bash-completion, wireguard-tools, qrencode, jq, unattended-upgrades, apt-listchanges, procps, e2fsprogs'
test "$(dpkg-deb --field "${DEB_NAME}" Depends)" = "${EXPECTED_DEPENDS}"
```

The control archive must contain one each of `control`, `md5sums`, `preinst`,
`postinst`, `prerm` and `postrm`:

```bash
diff --unified \
  <(printf '%s\n' ./ ./control ./md5sums ./postinst ./postrm ./preinst ./prerm | LC_ALL=C sort) \
  <(dpkg-deb --ctrl-tarfile "${DEB_NAME}" | tar --list --file=- | LC_ALL=C sort)
dpkg-deb --info "${DEB_NAME}"
```

Require the exact payload inventory attested for the v4.03.2 Debian amd64
package:

```bash
diff --unified \
  <(printf '%s\n' \
    ./ \
    ./opt/ \
    ./opt/syswarden/ \
    ./opt/syswarden/bin/ \
    ./opt/syswarden/bin/syswarden-cli \
    ./opt/syswarden/bin/syswarden-core \
    ./opt/syswarden/bin/syswarden-tui \
    ./opt/syswarden/signatures.json \
    ./usr/ \
    ./usr/local/ \
    ./usr/local/bin/ \
    ./usr/local/bin/syswarden \
    ./usr/local/bin/syswarden-tui \
    ./usr/share/ \
    ./usr/share/doc/ \
    ./usr/share/doc/syswarden/ \
    ./usr/share/doc/syswarden/changelog.gz | LC_ALL=C sort) \
  <(dpkg-deb --fsys-tarfile "${DEB_NAME}" | \
    tar --list --file=- | LC_ALL=C sort)

PAYLOAD_ROOT="$(mktemp -d /root/syswarden-v4032-payload.XXXXXXXX)"
chmod 0700 "${PAYLOAD_ROOT}"
dpkg-deb --extract "${DEB_NAME}" "${PAYLOAD_ROOT}"
test -x "${PAYLOAD_ROOT}/opt/syswarden/bin/syswarden-cli"
test -x "${PAYLOAD_ROOT}/opt/syswarden/bin/syswarden-core"
test -x "${PAYLOAD_ROOT}/opt/syswarden/bin/syswarden-tui"
test -f "${PAYLOAD_ROOT}/opt/syswarden/signatures.json"
test -f "${PAYLOAD_ROOT}/usr/share/doc/syswarden/changelog.gz"
test -L "${PAYLOAD_ROOT}/usr/local/bin/syswarden"
test -L "${PAYLOAD_ROOT}/usr/local/bin/syswarden-tui"
test "$(readlink "${PAYLOAD_ROOT}/usr/local/bin/syswarden")" = \
  /opt/syswarden/bin/syswarden-cli
test "$(readlink "${PAYLOAD_ROOT}/usr/local/bin/syswarden-tui")" = \
  /opt/syswarden/bin/syswarden-tui
```

Services and host configuration are generated by the runtime installation
pipeline, not shipped as opaque DEB payload files.

## 7. Preview migration with the candidate binary

The installed historical v4.02.8 updater must not be used for this first hop. Extract the
already verified v4.03.2 DEB into a new protected directory and run its
read-only migration validation:

```bash
set -euo pipefail
LEGACY_SOURCE=/opt/syswarden/syswarden-auto.conf
MIGRATION_BACKUP=/opt/syswarden/syswarden-auto.conf.migration_backup
MIGRATED_BACKUP=/opt/syswarden/syswarden-auto.conf.migration_backup.migrated
LEGACY_ARCHIVE=/opt/syswarden/syswarden-auto.conf.bak
MIGRATION_MARKER=/etc/syswarden/config/.migration-in-progress
for path in \
  "${LEGACY_SOURCE}" \
  "${MIGRATION_BACKUP}" \
  "${MIGRATED_BACKUP}" \
  "${LEGACY_ARCHIVE}" \
  "${MIGRATION_MARKER}"; do
  if sudo test -e "${path}" || sudo test -L "${path}"; then
    sudo stat --format='%n %F %U:%G %a %d:%i %s' "${path}"
  else
    printf 'ABSENT: %s\n' "${path}"
  fi
done
sudo test ! -e "${MIGRATION_MARKER}" && sudo test ! -L "${MIGRATION_MARKER}"
sudo test ! -e "${MIGRATION_BACKUP}" && sudo test ! -L "${MIGRATION_BACKUP}"
sudo test ! -e "${MIGRATED_BACKUP}" && sudo test ! -L "${MIGRATED_BACKUP}"
if sudo test -f "${LEGACY_SOURCE}" && sudo test ! -L "${LEGACY_SOURCE}"; then
  sudo test ! -e "${LEGACY_ARCHIVE}" && sudo test ! -L "${LEGACY_ARCHIVE}"
  MIGRATION_MODE=legacy
else
  sudo test ! -e "${LEGACY_SOURCE}" && sudo test ! -L "${LEGACY_SOURCE}"
  sudo test -f /etc/syswarden/config/config.toml
  MIGRATION_MODE=modular
fi

PREFLIGHT_ROOT="$(sudo mktemp -d /root/syswarden-v4032-preflight.XXXXXXXX)"
sudo chmod 0700 "${PREFLIGHT_ROOT}"
sudo dpkg-deb --extract "/root/syswarden-v4032-stage/${DEB_NAME}" "${PREFLIGHT_ROOT}"
CANDIDATE_CLI="${PREFLIGHT_ROOT}/opt/syswarden/bin/syswarden-cli"
sudo test -x "${CANDIDATE_CLI}"
sudo test ! -e /etc/syswarden/config/.migration-in-progress

capture_config_state() {
  local root=$1
  local output=$2
  if sudo test ! -e "${root}" && sudo test ! -L "${root}"; then
    printf 'ABSENT %s\n' "${root}" > "${output}"
    return
  fi
  {
    sudo find -P "${root}" -xdev \
      -printf 'META %p %y %m %U %G %s %T@\n'
    sudo find -P "${root}" -xdev -type f -exec sha256sum -- '{}' +
    sudo find -P "${root}" -xdev -type l -printf 'LINK %p %l\n'
  } | LC_ALL=C sort > "${output}"
}

show_toml_value() {
  local file=$1
  local section=$2
  local key=$3
  sudo awk -v section="${section}" -v key="${key}" '
    /^[[:space:]]*\[/ {
      current = $0
      gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", current)
    }
    current == section && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
      count++
      print section "." $0
    }
    END { exit(count == 1 ? 0 : 1) }
  ' "${file}"
}

SOURCE_STATE_BEFORE="${PREFLIGHT_ROOT}/production-source.before"
CONFIG_STATE_BEFORE="${PREFLIGHT_ROOT}/production-config.before"
SOURCE_STATE_AFTER="${PREFLIGHT_ROOT}/production-source.after"
CONFIG_STATE_AFTER="${PREFLIGHT_ROOT}/production-config.after"
capture_config_state "${LEGACY_SOURCE}" "${SOURCE_STATE_BEFORE}"
capture_config_state /etc/syswarden/config "${CONFIG_STATE_BEFORE}"

if test "${MIGRATION_MODE}" = legacy; then
  sudo env -i HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin \
    "${CANDIDATE_CLI}" config migrate \
    --source /opt/syswarden/syswarden-auto.conf \
    --output /etc/syswarden/config \
    --dry-run
else
  sudo env -i HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin \
    "${CANDIDATE_CLI}" config validate --path /etc/syswarden/config
fi

capture_config_state "${LEGACY_SOURCE}" "${SOURCE_STATE_AFTER}"
capture_config_state /etc/syswarden/config "${CONFIG_STATE_AFTER}"
diff --unified "${SOURCE_STATE_BEFORE}" "${SOURCE_STATE_AFTER}"
diff --unified "${CONFIG_STATE_BEFORE}" "${CONFIG_STATE_AFTER}"
```

`--dry-run` performs no source or destination writes. It also refuses to resume
an existing transaction marker. The two fail-closed diffs must produce no
output. They bind that promise to the production source and destination rather
than only to the explicit migration code path. Review every error instead of
modifying the source to force success.

The dry run validates semantics but intentionally does not print the migrated
choices. For a legacy source, migrate a protected copy into a disposable output
directory, validate it with the candidate, and print only the non-secret values
that require operator approval. Continue in the same trusted root shell so the
protected variables and helper functions defined above remain in scope:

```bash
set -euo pipefail
test "${MIGRATION_MODE}" = legacy
REVIEW_ROOT="$(sudo mktemp -d /root/syswarden-v4032-review.XXXXXXXX)"
sudo chmod 0700 "${REVIEW_ROOT}"
REVIEW_SOURCE="${REVIEW_ROOT}/syswarden-auto.conf"
REVIEW_OUTPUT="${REVIEW_ROOT}/config"
sudo install --mode=0600 --owner=root --group=root \
  "${LEGACY_SOURCE}" "${REVIEW_SOURCE}"

sudo env -i HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin \
  "${CANDIDATE_CLI}" config migrate \
  --source "${REVIEW_SOURCE}" \
  --output "${REVIEW_OUTPUT}"
sudo env -i HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin \
  "${CANDIDATE_CLI}" config validate --path "${REVIEW_OUTPUT}"

show_toml_value "${REVIEW_OUTPUT}/modules/00-core.toml" \
  core firewall_backend
show_toml_value "${REVIEW_OUTPUT}/modules/00-core.toml" \
  core ssh_port
show_toml_value "${REVIEW_OUTPUT}/modules/10-network.toml" \
  network.wireguard enabled
show_toml_value "${REVIEW_OUTPUT}/modules/10-network.toml" \
  network.wireguard port
show_toml_value "${REVIEW_OUTPUT}/modules/10-network.toml" \
  network.wireguard subnet
show_toml_value "${REVIEW_OUTPUT}/modules/40-integrations.toml" \
  integrations.ha enabled
show_toml_value "${REVIEW_OUTPUT}/modules/40-integrations.toml" \
  integrations.ha peer_ips
show_toml_value "${REVIEW_OUTPUT}/modules/40-integrations.toml" \
  integrations.ha peer_port

capture_config_state "${LEGACY_SOURCE}" "${SOURCE_STATE_AFTER}"
capture_config_state /etc/syswarden/config "${CONFIG_STATE_AFTER}"
diff --unified "${SOURCE_STATE_BEFORE}" "${SOURCE_STATE_AFTER}"
diff --unified "${CONFIG_STATE_BEFORE}" "${CONFIG_STATE_AFTER}"
```

The migration of the copy can rename or securely wipe only `REVIEW_SOURCE`;
that is why it must live below the disposable root. The repeated production
diffs must remain empty. Store the printed network values in protected evidence
and do not publish internal addresses. Stop unless the firewall backend, actual
SSH port behavior, WireGuard state and port or subnet, and HA state, peers and
port are explicitly approved.

If the host already has a complete modular configuration, validate it with the
candidate instead, print the same non-secret decision keys with
`show_toml_value`, and prove the exact seven-file inventory exists:

```bash
sudo env -i HOME=/root PATH=/usr/sbin:/usr/bin:/sbin:/bin \
  "${CANDIDATE_CLI}" config validate --path /etc/syswarden/config
diff --unified \
  <(printf '%s\n' \
    'config.toml f' \
    'modules d' \
    'modules/00-core.toml f' \
    'modules/10-network.toml f' \
    'modules/20-security.toml f' \
    'modules/30-waap.toml f' \
    'modules/40-integrations.toml f' \
    'modules/99-user.toml f' | LC_ALL=C sort) \
  <(sudo find /etc/syswarden/config -xdev -mindepth 1 \
    -printf '%P %y\n' | LC_ALL=C sort)
sudo stat /etc/syswarden/config/config.toml \
  /etc/syswarden/config/modules/00-core.toml \
  /etc/syswarden/config/modules/10-network.toml \
  /etc/syswarden/config/modules/20-security.toml \
  /etc/syswarden/config/modules/30-waap.toml \
  /etc/syswarden/config/modules/40-integrations.toml \
  /etc/syswarden/config/modules/99-user.toml
show_toml_value /etc/syswarden/config/modules/00-core.toml \
  core firewall_backend
show_toml_value /etc/syswarden/config/modules/00-core.toml \
  core ssh_port
show_toml_value /etc/syswarden/config/modules/10-network.toml \
  network.wireguard enabled
show_toml_value /etc/syswarden/config/modules/10-network.toml \
  network.wireguard port
show_toml_value /etc/syswarden/config/modules/10-network.toml \
  network.wireguard subnet
show_toml_value /etc/syswarden/config/modules/40-integrations.toml \
  integrations.ha enabled
show_toml_value /etc/syswarden/config/modules/40-integrations.toml \
  integrations.ha peer_ips
show_toml_value /etc/syswarden/config/modules/40-integrations.toml \
  integrations.ha peer_port
```

Migration maps a historical `firewalld` backend to `keep`, which preserves the
operator-managed frontend without automatically changing services. A migrated
`nftables` backend requires active and enabled `nftables.service` with
firewalld, UFW, iptables services and netfilter-persistent inactive and
disabled. The legacy `iptables` value remains parseable but is rejected by
v4.03.2 operational policy mutation. Enabled WireGuard requires `nftables`.

## 8. Review the package transaction plan

First prove that no package manager is already active. This is a live
pre-install gate, separate from interrupted-transaction recovery:

```bash
set -euo pipefail
if ps -eo pid=,comm=,args= | awk '
  $2 ~ /^(apt|apt-get|dpkg|unattended-upgr)$/ { print; found = 1 }
  END { exit(found ? 0 : 1) }
'; then
  printf '%s\n' 'A package-manager process is active; stop the migration.' >&2
  exit 1
else
  PROCESS_PIPE_STATUS=("${PIPESTATUS[@]}")
  if (( PROCESS_PIPE_STATUS[0] != 0 || PROCESS_PIPE_STATUS[1] != 1 )); then
    printf 'Package-manager probe failed: ps=%d awk=%d\n' \
      "${PROCESS_PIPE_STATUS[0]}" "${PROCESS_PIPE_STATUS[1]}" >&2
    exit 1
  fi
fi
DPKG_AUDIT="$(sudo dpkg --audit)"
if [[ -n "${DPKG_AUDIT}" ]]; then
  printf '%s\n' "${DPKG_AUDIT}" >&2
  printf '%s\n' 'dpkg reports unfinished or inconsistent package state.' >&2
  exit 1
fi
```

Simulation does not execute maintainer scripts:

```bash
cd /root/syswarden-v4032-stage
sudo apt-get --simulate install "./${DEB_NAME}"
```

Stop if the plan removes or downgrades anything, changes architecture, selects
an unexpected repository, or changes unrelated packages. Resolve missing
dependencies under normal Debian change control before continuing.

## 9. Understand the actual hook sequence

During `apt-get install`, v4.03.2 performs these ordered operations:

1. `preinst` exports the package-install boundary, creates or tightens the
   private SysWarden directories, rejects unsafe symlinks and retires only
   byte-attributed historical Web TUI state.
2. If `/opt/syswarden/syswarden-auto.conf` exists and neither migration backup
   state exists, `preinst` renames it to
   `/opt/syswarden/syswarden-auto.conf.migration_backup`.
3. `dpkg` unpacks the new binaries and launcher links.
4. `postinst` invokes the new v4.03.2 `migrate-config` implementation before
   the host installation pipeline. A transaction marker at
   `/etc/syswarden/config/.migration-in-progress` makes a retry resume instead
   of silently starting over.
5. Migration validates and atomically publishes `schema_version = 1`, the
   master file and all six modules. It preserves a validated operator-owned
   `99-user.toml` instead of overwriting it.
6. When `secure_wipe_conf = false`, the migrated source is retained and
   post-install moves it to `/opt/syswarden/syswarden-auto.conf.bak` without
   overwriting an existing archive. When secure wipe is explicitly enabled,
   the source is securely finalized and the protected pre-upgrade backup is the
   recovery copy.
7. On an active systemd host, `postinst` runs `syswarden-cli install`. This
   pipeline validates configuration and firewall state, then configures
   dependencies, SSH, threat feeds, firewall policy, integrations, hardening,
   WireGuard or HA, systemd services and scheduled jobs.
8. The package enables and starts `syswarden-firewall.service`, then enables,
   starts and restarts `syswarden-core.service`.

Every migration and main host-installation failure propagates to `dpkg`. The
optional Bash-completion rendering step can be skipped by the package hook, so
its output must be checked separately after installation. There is no fail-open
migration path and no package-script rollback implementation.

## 10. Install v4.03.2

> [!CAUTION]
> **TARGET VALIDATION REQUIRED:** Keep the recovery console and the original
> SSH session open. Start a separate terminal that continuously observes the
> package log and service journal.

Install only the already verified local file:

```bash
set -euo pipefail
cd /root/syswarden-v4032-stage
if ps -eo pid=,comm=,args= | awk '
  $2 ~ /^(apt|apt-get|dpkg|unattended-upgr)$/ { print; found = 1 }
  END { exit(found ? 0 : 1) }
'; then
  printf '%s\n' 'A package-manager process became active; stop the migration.' >&2
  exit 1
else
  PROCESS_PIPE_STATUS=("${PIPESTATUS[@]}")
  if (( PROCESS_PIPE_STATUS[0] != 0 || PROCESS_PIPE_STATUS[1] != 1 )); then
    printf 'Package-manager probe failed: ps=%d awk=%d\n' \
      "${PROCESS_PIPE_STATUS[0]}" "${PROCESS_PIPE_STATUS[1]}" >&2
    exit 1
  fi
fi
DPKG_AUDIT="$(sudo dpkg --audit)"
if [[ -n "${DPKG_AUDIT}" ]]; then
  printf '%s\n' "${DPKG_AUDIT}" >&2
  printf '%s\n' 'dpkg reports unfinished or inconsistent package state.' >&2
  exit 1
fi
sudo env DEBIAN_FRONTEND=noninteractive \
  apt-get --no-remove install -y "./${DEB_NAME}"
```

Do not close the recovery session when `apt-get` returns. A successful package
exit is necessary but is not sufficient for acceptance.

## 11. Validate package and migration state

```bash
set -euo pipefail
test "$(dpkg-query --show --showformat='${Status}' syswarden)" = 'install ok installed'
test "$(dpkg-query --show --showformat='${Version}' syswarden)" = '4.03.2'
test "$(dpkg-query --show --showformat='${Architecture}' syswarden)" = \
  "$(dpkg --print-architecture)"
DPKG_AUDIT="$(sudo dpkg --audit)"
if [[ -n "${DPKG_AUDIT}" ]]; then
  printf '%s\n' "${DPKG_AUDIT}" >&2
  printf '%s\n' 'dpkg reports unfinished or inconsistent package state.' >&2
  exit 1
fi
sudo /opt/syswarden/bin/syswarden-cli
sudo syswarden config validate --path /etc/syswarden/config
sudo test ! -e /etc/syswarden/config/.migration-in-progress
```

Confirm the complete modular inventory and record hashes:

```bash
diff --unified \
  <(printf '%s\n' \
    'config.toml f' \
    'modules d' \
    'modules/00-core.toml f' \
    'modules/10-network.toml f' \
    'modules/20-security.toml f' \
    'modules/30-waap.toml f' \
    'modules/40-integrations.toml f' \
    'modules/99-user.toml f' | LC_ALL=C sort) \
  <(sudo find /etc/syswarden/config -xdev -mindepth 1 \
    -printf '%P %y\n' | LC_ALL=C sort)
sudo stat --format='%n %F %U:%G %a %d:%i %s' \
  /etc/syswarden/config/config.toml \
  /etc/syswarden/config/modules/00-core.toml \
  /etc/syswarden/config/modules/10-network.toml \
  /etc/syswarden/config/modules/20-security.toml \
  /etc/syswarden/config/modules/30-waap.toml \
  /etc/syswarden/config/modules/40-integrations.toml \
  /etc/syswarden/config/modules/99-user.toml
sudo find /etc/syswarden/config -xdev -type f -exec sha256sum -- '{}' +
sudo test -s /etc/bash_completion.d/syswarden
sudo syswarden config-get core.firewall_backend
sudo syswarden config-get core.ssh_port
sudo syswarden config-get network.wireguard.enabled
sudo syswarden config-get waap.enforcement_mode
sudo syswarden config-get integrations.ha.enabled
```

Review all unknown and deprecated-key diagnostics even when structural
validation succeeds. Do not publish or paste values that contain secrets.

If secure wipe was disabled, confirm the historical archive exists as a real,
root-controlled regular file:

```bash
sudo stat --format='%n %F %U:%G %a %d:%i %s' \
  /opt/syswarden/syswarden-auto.conf.bak
```

Do not expect that archive when secure wipe was explicitly enabled. In either
case, retain the protected snapshot and backup until final acceptance.

## 12. Validate systemd, firewall and SSH

> [!IMPORTANT]
> **TARGET VALIDATION REQUIRED:** Capture every command and exit status.

```bash
sudo systemctl is-enabled syswarden-firewall.service
sudo systemctl is-active syswarden-firewall.service
sudo systemctl is-enabled syswarden-core.service
sudo systemctl is-active syswarden-core.service
sudo systemctl show syswarden-firewall.service syswarden-core.service \
  --property=Id,LoadState,ActiveState,SubState,UnitFileState,FragmentPath,DropInPaths,ExecMainStatus,MemoryCurrent,CPUUsageNSec
sudo systemd-analyze verify \
  /etc/systemd/system/syswarden-firewall.service \
  /etc/systemd/system/syswarden-core.service
sudo journalctl --boot --no-pager \
  --unit=syswarden-firewall.service --unit=syswarden-core.service
```

Validate the effective firewall without changing lists:

```bash
sudo syswarden config-get core.firewall_backend
sudo nft --handle list table inet syswarden
sudo nft --handle list table netdev syswarden_hw_drop
sudo syswarden list
sudo syswarden audit
```

`syswarden audit` is a bounded local operational diagnostic, not a compliance
certificate and not a complete kernel-state proof. Compare the entire ruleset
and frontend state against the protected before-state evidence. Confirm that
the administrative source, effective SSH port, HA peer port and any required
WireGuard state remain reachable and non-conflicting.

Validate SSH before closing the original session:

```bash
sudo sshd -t
sudo sshd -T
sudo systemctl is-active ssh.service
sudo ss -lntpH
```

Open a new SSH connection through the effective port from an approved
administrative source. Keep the original session open until the new session
has run a privileged read-only command successfully. If the new connection
fails, use the provider console and invoke the snapshot rollback decision.

## 13. Validate HIDS, HIPS and WAAP-oriented capabilities

These labels describe capability areas, not a certification claim. Separate
read-only production smoke checks from end-to-end mutation tests.

### 13.1 Read-only checks on the upgraded host

```bash
sudo syswarden audit
sudo journalctl --boot --no-pager --unit=syswarden-core.service
sudo stat --format='%n %F %U:%G %a %s' \
  /run/syswarden.sock \
  /var/log/syswarden/waf.json \
  /var/lib/syswarden/ui/data.json
sudo syswarden config-get waap.enforcement_mode
sudo syswarden config-get waap.bruteforce_logs
sudo syswarden config-get waap.modsec_logs
```

From an interactive trusted terminal, run `sudo syswarden alerts`, verify that
existing kernel and WAAP events render without parser errors, then exit with
Ctrl+C. This observes available telemetry but does not prove an end-to-end
detection or prevention response.

Confirm all of the following in protected evidence:

- the core consumes the configured real log paths and follows rotations;
- the WAAP socket and JSON telemetry file have expected ownership and modes;
- ambiguous, malformed or hostless records do not create a firewall target;
- the authoritative SysWarden nftables tables are loaded;
- persistent blocklist, whitelist and SSH-exception registries are intact;
- no new high-severity service, parser, firewall or kernel errors appear.

### 13.2 End-to-end tests on a restored clone

Do not generate attack traffic or change production policy merely to satisfy a
smoke test. On a disposable clone restored from the pre-upgrade snapshot:

1. Exercise one manual block with the documentation address `192.0.2.25`:

   ```bash
   sudo syswarden block 192.0.2.25
   sudo syswarden check 192.0.2.25
   sudo nft --handle list table inet syswarden
   sudo syswarden unblock 192.0.2.25
   ```

2. Follow [Observe one web log in WAAP audit mode](https://syswarden.io/docs/use-cases/#1-observe-one-web-log-in-waap-audit-mode)
   with a known compatible application log. Require simulated telemetry in
   audit mode before any enforcing-mode test.
3. Exercise a representative HIDS event from an isolated test source and prove
   the event appears in the journal and `syswarden alerts` without exposing raw
   secrets or copying attacker-controlled text into an operator command.
4. If enforcing mode is in scope, prove the durable list change, matching
   nftables state, expiry or explicit cleanup, service health and unrelated
   traffic preservation.
5. Restore the clone snapshot after collecting evidence.

## 14. Validate TUI and network-terminal retirement

Run the native dashboard from the trusted terminal:

```bash
sudo syswarden tui
```

Verify that the dashboard renders local telemetry and, when configured,
authenticated HA status. Exit with `q` or `Q`. TUI rendering is observability,
not kernel-state proof.

Confirm SysWarden owns no browser terminal, HTTPS or WebSocket terminal bridge,
remote PTY route, token-management command, listener or generated firewall
permission on TCP 62027:

```bash
if sudo ss -lntpH | awk '$4 ~ /:62027$/ {print}'; then
  :
else
  SOCKET_PIPE_STATUS=("${PIPESTATUS[@]}")
  printf 'Listener probe failed: ss=%d awk=%d\n' \
    "${SOCKET_PIPE_STATUS[0]}" "${SOCKET_PIPE_STATUS[1]}" >&2
  exit 1
fi

if sudo nft --handle list ruleset | grep -F '62027'; then
  printf '%s\n' 'Review and attribute every matching rule before acceptance.'
else
  NFT_PIPE_STATUS=("${PIPESTATUS[@]}")
  if (( NFT_PIPE_STATUS[0] == 0 && NFT_PIPE_STATUS[1] == 1 )); then
    printf '%s\n' 'No nftables rule contains TCP port 62027.'
  else
    printf 'Firewall probe failed: nft=%d grep=%d\n' \
      "${NFT_PIPE_STATUS[0]}" "${NFT_PIPE_STATUS[1]}" >&2
    exit 1
  fi
fi
```

No listener or firewall-rule match is expected for SysWarden-owned state. The
probe status messages are expected. If an unrelated process or rule was
recorded before the upgrade, prove it was preserved and remains unattributed to
SysWarden.

## 15. Validate the signed-update surface safely

The v4.03.2 CLI includes a fail-closed signed updater:

```bash
sudo syswarden update --help
```

The command has no check-only or version-pin mode. It installs a newer public
release when one exists, so do not run it as a production smoke test. Query the
public release API only to record the latest tag at that observation time:

```bash
curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \
  --connect-timeout 15 --max-time 60 --retry 3 \
  https://api.github.com/repos/duggytuxy/syswarden/releases/latest | \
  jq -r '.tag_name'
```

Do not follow that observation with `sudo syswarden update` on the production
host: a newer release can appear between the API query and the updater lookup,
causing an unplanned installation. Run the mutating updater test only on a
disposable restored clone under separate change control. Full Ed25519 manifest,
platform, size, digest and installation behavior requires that clone evidence,
a newer qualified release or the sealed release qualification evidence.

## 16. Recover an interrupted `dpkg` transaction

Use this section only when installation was interrupted or `dpkg` reports the
package unpacked but not configured. Do not delete locks, the migration marker,
the migration backup, the retained archive or any temporary migration file.

First preserve evidence and confirm that no package manager is still running:

```bash
sudo dpkg --audit
dpkg-query --show --showformat='${db:Status-Abbrev} ${Status} ${Version} ${Architecture}\n' syswarden
if ps -eo pid,ppid,user,lstart,args | \
  grep -E '[a]pt|[d]pkg|[u]nattended-upgrade'; then
  printf '%s\n' 'A package-manager process is still active; stop recovery.' >&2
  exit 1
else
  PROCESS_PIPE_STATUS=("${PIPESTATUS[@]}")
  if (( PROCESS_PIPE_STATUS[0] != 0 || PROCESS_PIPE_STATUS[1] != 1 )); then
    printf 'Process probe failed: ps=%d grep=%d\n' \
      "${PROCESS_PIPE_STATUS[0]}" "${PROCESS_PIPE_STATUS[1]}" >&2
    exit 1
  fi
fi
for path in \
  /etc/syswarden/config/.migration-in-progress \
  /opt/syswarden/syswarden-auto.conf.migration_backup \
  /opt/syswarden/syswarden-auto.conf.migration_backup.migrated \
  /opt/syswarden/syswarden-auto.conf.bak; do
  if sudo test -e "${path}" || sudo test -L "${path}"; then
    sudo stat --format='%n %F %U:%G %a %d:%i %s' "${path}"
  else
    printf 'ABSENT: %s\n' "${path}"
  fi
done
sudo journalctl --boot --no-pager \
  --unit=syswarden-firewall.service --unit=syswarden-core.service
sudo tail -n 200 /var/log/dpkg.log
```

Optional paths can be absent. Record that fact. If no `apt`, `dpkg` or
unattended-upgrade process is active, resume configuration once:

```bash
sudo dpkg --configure -a
```

The v4.03.2 post-install state machine is designed to resume the exact
transaction marker and retained source. A dry run cannot resume an in-progress
transaction and is not the recovery command.

If dependency errors remain, inspect the proposed repair without applying it:

```bash
sudo apt-get --simulate --fix-broken install
```

Apply `apt-get --fix-broken install` only after a human confirms that the plan
installs the declared dependencies and configures the verified v4.03.2 package
without removal, downgrade, architecture change or unrelated package changes.
Then repeat the complete post-upgrade validation.

Restore the snapshot instead of improvising if recovery reports any of these:

- archive collision or changed migration-source identity;
- unsafe symlink, owner, mode or directory identity;
- unexpected or changed transaction marker;
- incomplete or invalid modular configuration;
- ambiguous firewall or service-manager state;
- failed SSH recovery path;
- persistent package, service or kernel-policy inconsistency.

## 17. Preferred snapshot rollback

Rollback is a change-control decision. Trigger it when acceptance cannot be
completed safely, administrative access is at risk, or recovery would require
overriding a fail-closed guard.

SysWarden package rollback is an explicit package and configuration recovery
procedure, not a general host-state reversal. The preferred provider or
hypervisor snapshot restoration below is different: it is a whole-host state
reversal and can roll back unrelated application data.

1. Stop new operator and workload changes, preserve the current console,
   package, migration, service and firewall evidence, and capture any forward
   application data required by the approved reconciliation plan.
2. Confirm the recorded snapshot scope, application recovery point and approval
   for unrelated state reversal. Do not restore while unquiesced writers can
   create an inconsistent snapshot or overwrite newer durable data.
3. Use the provider or hypervisor console to restore the complete pre-upgrade
   snapshot. Do not attempt to reconstruct historical v4.02.8 state by copying
   old binaries onto migrated v4.03.2 configuration.
4. Boot the restored snapshot in an isolated or controlled network context,
   then apply the approved application-data replay or reconciliation procedure.
5. Prove Debian 13, package version `4.02.8`, SSH access, service state,
   firewall rules, persistent lists, WAAP inputs, scheduled jobs and HA or
   WireGuard state match the recorded before-state evidence.
6. Keep the candidate package, logs and protected backup for root-cause
   analysis. Do not resume the campaign until the cause and corrected procedure
   are independently reviewed.

A direct package downgrade was exercised in disposable lifecycle qualification,
but it is not the preferred production rollback. Package hooks contain no
general host rollback implementation, and an older binary must not be assumed
compatible with already migrated configuration or retired network surfaces.

## 18. Acceptance and evidence checklist

The migration is complete only when every applicable item is attached to the
internal change record and contains no exposed secret:

- [ ] Change identifier, operator, UTC start and end times.
- [ ] Target identity recorded internally without publishing it.
- [ ] Debian 13, kernel and architecture before and after.
- [ ] Snapshot identifier and successful restore-clone evidence.
- [ ] Root-only backup path, archive SHA-256, verified encrypted off-host copy
      and protected-storage location.
- [ ] Source package status, version, architecture and binary hashes.
- [ ] Public Release ID, tag, resolved 40-character commit SHA and exact asset
      inventory.
- [ ] Successful GitHub provenance attestation for the selected DEB, exact
      signer workflow and exact source SHA.
- [ ] Independently approved package digest, package-manifest digest,
      release-manifest digest and post-transfer digest.
- [ ] Exact DEB package, version, architecture, dependency and control-script
      metadata.
- [ ] Successful candidate migration dry run.
- [ ] `apt-get --simulate` output with no removal, downgrade or unrelated
      change.
- [ ] Complete package installation output and relevant `dpkg.log` and APT
      history.
- [ ] Modular schema validation, seven-file inventory and protected hashes.
- [ ] Migration marker absent and expected archive or secure-wipe outcome
      recorded.
- [ ] Firewall backend, complete ruleset comparison and persistent registry
      validation.
- [ ] Both SysWarden systemd units enabled and active with clean journals.
- [ ] Effective SSH validation and successful independent new SSH session.
- [ ] HIDS, HIPS and WAAP-oriented read-only smoke evidence.
- [ ] Snapshot-clone end-to-end firewall and WAAP audit-mode evidence.
- [ ] Native TUI renders and exits cleanly.
- [ ] No SysWarden network-terminal state or TCP 62027 ownership.
- [ ] Signed-update command surface and current public latest tag recorded.
- [ ] CPU, memory and service stability compared with the before-state.
- [ ] Final production go or rollback decision with reviewer sign-off.

Keep the snapshot and protected backup through the agreed observation period.
Only the backup owner may authorize later disposal under the organization's
retention policy.
