# Deployment Tutorial

> Status: Current
> Documentation baseline: v4.04.3

This tutorial describes the Linux-only v4.04.3 release contract. The
version-specific v4.04.0 change set was first published in v4.04.2 and is
carried forward in v4.04.3. Use a version only after its exact Release inventory
is public and the protected qualification is green for this historical
v4.04.3 contract. The [new IVV/IVVQ strategy](https://syswarden.io/docs/release-assurance/)
defines future assurance by release track; it does not alter this baseline's
package inventory or recorded acceptance requirements.

For local testing of newer source, use the separate
[build and install from source procedure](https://syswarden.io/docs/build-from-source/).
Its version-specific candidate instructions do not change this stable release baseline.

Keep verified console or SSH recovery access before changing packages,
firewall, SSH, HA or migration state.

## 1. Supported package matrix

| Distribution family | Architecture | Expected package |
|---|---|---|
| Debian or Ubuntu | amd64 | `syswarden_4.04.3_amd64.deb` |
| Fedora or RHEL family | x86_64 | `syswarden-4.04.3-1.x86_64.rpm` |
| Alpine | x86_64 | `syswarden_4.04.3_x86_64.apk` |

The package workflow is configured to generate one DEB, one RPM and one APK
package plus `SHA256SUMS.txt`. No current package, updater or qualification
route exists outside this AMD64/x86_64 matrix. Native package lifecycle
evidence, not a filename or successful build, decides whether a target is
releasable.

## 2. Recovery prerequisites

Before installation or upgrade:

1. retain a verified local console or second SSH session;
2. save `/etc/syswarden`, required list files and HA trust material;
3. save the active firewall service state, nftables ruleset and installed package version;
4. verify that TCP 62027 is blocked at the host boundary;
5. verify that the applicable recovery artifact and recovery channel work on a
   representative host. For the version-specific migration to v4.03.3, that
   artifact is a complete pre-upgrade VM or volume snapshot; no in-place package
   downgrade is supported;
6. record any unrelated process that already owns a path, service name or port
   that migration cleanup may inspect.

Do not rely on an HA withdrawal, package hook or remote dashboard as the only
recovery path.

## 3. Exact public release inventory

A qualified v4.04.3 Release contains exactly these ten assets:

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

Reject a Release with a missing, duplicate or unexpected asset. The package
checksum inventory, release checksum inventory and detached Ed25519 update
signature must all verify before publication or installation.

## 4. Verify and install one package

For packages built from the latest source, follow
[Build and Install from Source](https://syswarden.io/docs/build-from-source/).
The steps below use the qualified stable release.

Download exactly one package matching the host and `SHA256SUMS.txt` from the
same Release. Verify the selected package before invoking its package manager:

```console
sha256sum --check --ignore-missing SHA256SUMS.txt
```

Stop unless the selected filename appears exactly once in the manifest and is
reported as valid. On Debian, Ubuntu, Fedora or a RHEL-family host, run only the
matching command:

```console
sudo apt-get update
sudo apt-get install -y ./syswarden_4.04.3_amd64.deb
sudo dnf install -y ./syswarden-4.04.3-1.x86_64.rpm
```

The APT index refresh uses only the distribution repositories already
configured on the host. Stop if those sources are unsigned, unexpected or do
not expose every declared package dependency. Do not enable an unreviewed
third-party repository to make the transaction proceed.

On an online Alpine host, prepare Cronie first when automatic activation is
required:

```sh
set -eu
sudo apk add --no-cache cronie cronie-openrc
for service in crond cronie; do
  if sudo rc-service "${service}" status >/dev/null 2>&1; then
    sudo rc-service "${service}" stop
  fi
  for link in /etc/runlevels/*/"${service}"; do
    [ -L "${link}" ] || continue
    sudo rc-update del "${service}" "$(basename "$(dirname "${link}")")"
  done
done
sudo rc-update add cronie default
sudo rc-service cronie start
sudo apk add --allow-untrusted ./syswarden_4.04.3_x86_64.apk
```

The automatic path requires committed `cronie` and `cronie-openrc`, an active
Cronie service assigned only to the default runlevel, and an inactive BusyBox
`crond` with no runlevel. apk-tools 3 can commit package payload after a failed
pre-install script, so v4.04.3 does not return a failing fresh hook for an
unprepared scheduler. A fresh APK transaction without prepared Cronie remains
payload-only and inactive: no SysWarden configuration, data, logs, runtime,
cron, service, process or firewall policy is created, and the package is not
marked broken. The immutable payload already contains the two package-owned
launchers, but neither performs activation.

For the deferred payload-only path, the package prints a bounded two-phase
activation block. It validates the complete OpenRC inventory before its first
mutation, executes fail-fast and reattests the exact provider and runlevel state
before activation. Run that emitted block exactly, only after the initial APK
transaction has completed and both Cronie packages are present. Do not replace
it with shortened scheduler commands. If the exact emitted block and its
package-manager log are unavailable, do not activate the payload manually;
repeat the transaction on a disposable recovered host and preserve the output.

`--allow-untrusted` is acceptable only after the exact SHA-256 check has
succeeded. A prepared automatic installation or the exact deferred activation
path mutates dependencies, services, firewall policy, hardening and scheduled
jobs. Review package scripts and use a disposable lifecycle host before
production rollout.

During installation, the supported multi-origin OSINT path may encounter a
syntactically valid private, 6to4 or other non-public or special-use host entry.
The candidate discards that entry before consensus and emits a bounded warning
containing only the normalized origin and discarded-entry count. It does not
publish the entry. Malformed syntax, an insufficient source after filtering and
custom feed validation failures still stop installation. On DEB hosts, verify
that package configuration completed and that `dpkg --audit` reports no
half-configured package before continuing.

New Data-Shield content still requires matching canonical bytes from at least
two independent HTTPS origins. During package installation only, a validly
configured external fetch failure, rejected remote candidates or content
disagreement preserves an exact validated last-known-good file; if none exists,
the optional Data-Shield contribution is omitted and the independent OSINT path
continues. One mirror is never published alone. Invalid mirror configuration,
caller cancellation, unsafe local state and publication errors remain fatal.
The explicit or scheduled `update-feeds` path reports quorum loss as a failure
after reapplying validated policy, so monitoring remains truthful.

### Version-specific GeoIP and blocklist sources in v4.04.0

The historical v4.04.0 change set was first published in the
historical v4.04.2 release and is carried forward in v4.04.3. It embeds one
deterministic GeoIP allocation snapshot inside the SysWarden CLI. It is
generated from the
CC0-1.0 [IPverse country-ip-blocks](https://github.com/ipverse/country-ip-blocks)
repository, whose country files represent RIR allocation or delegation data.
The generator validates the immutable source archive, its complete CC0 legal
text, the JSON and plaintext forms of every address family, canonical public
prefixes and deterministic accounting before embedding. Runtime validation
then revalidates the complete snapshot structure and content identity.
If a source allocation only partly overlaps special-use space, the generator
removes the reserved portion and retains the exact canonical public remainder;
it does not discard the whole public allocation.

The reviewed source boundary is exact:

- commit: `7443b1e07cae182ac864a2a4247ea4b641970dfe`;
- commit timestamp: `2026-08-28T01:55:22Z`;
- archive size: `1760020` bytes;
- archive SHA-256:
  `ab2de300d06fcf63cdaac7f3cb093d17a1d509b2a32455c731e18331506b28a8`;
- CC0 license SHA-256:
  `a2010f343487d3f7618affe54f789f5487602331c0a8d03f49e9a7c547cf0499`;
- embedded snapshot ID: `ipverse-20260828T015522Z-07f1cb9cae15413e`;
- embedded snapshot gzip SHA-256:
  `d467540cef649539488dbeab76dbf808349d000a6fedfef7cdc66735e2339400`.

The exact source URL, commit, timestamp, archive size, archive digest and
license digest are part of the embedded snapshot metadata. Only an official
SysWarden release whose signed manifest and package digest verify successfully
authenticates the reviewed CLI and snapshot bytes. A local or otherwise
unsigned build provides deterministic content binding and structural
validation, not official release authenticity.

Both `network.geo.blocked_countries` and strict
`network.geo.allowed_countries` select prefixes directly from this embedded
snapshot.

Runtime operation performs no IPverse or other country-data request. It does
not read or require `<country>.ipv4`, `<country>.ipv6`,
`allowed_<country>.ipv4` or `allowed_<country>.ipv6` files in
`/etc/syswarden/lists`, and existing country files cannot override the embedded
selection.

All 249 canonical ISO 3166 alpha-2 codes are valid configuration values. The
pinned source has no allocation data for `bv`, `cc`, `cx`, `eh`, `gs`, `hm`,
`pn`, `sh`, `sj`, `tf` and `um`; these remain valid codes with empty IPv4 and
IPv6 contributions. It has IPv4 data but no IPv6 data for `cf`, `er`, `fk`,
`kp`, `ms` and `yt`; that missing family is a valid empty contribution. Empty
contributions add nothing to a deny selection.

Strict allow policy is intentionally more restrictive. SysWarden merges the
configured GeoIP and operator-provisioned ASN allow populations before any
nftables mutation. If both merged IPv4 and IPv6 populations are empty, policy
application is rejected before mutation. If exactly one merged family is empty,
the empty set remains fail-closed and all non-whitelisted traffic for that
family is denied. Qualify both address families and preserve verified recovery
access before enabling strict allow mode.

`eu` and `zz` are not supported country codes. Replace either legacy value with
the explicit canonical ISO country codes required by the policy before upgrade,
configuration validation or reload. SysWarden never expands `eu` into a mutable
regional grouping and never treats `zz` as a country.

`syswarden update-feeds` validates every configured country code against the
embedded snapshot and reports its immutable snapshot identifier and selection
counts. It performs no country-data network request. Outside LAN mode, the
command separately refreshes configured external Data-Shield, custom and OSINT
inputs, so one of those external paths can still fail independently.

IP country data describes address allocation or assignment and is not an
authoritative statement of where an address is currently used. Routing,
transfers, anycast, VPNs and proxies can make allocation-based policy differ
from physical location. The pinned source currently attributes
`199.19.76.0/23` to both `nl` and `us`; either country selection therefore
contains that prefix. Treat strict country allow mode as a lockout-sensitive
allocation policy, not an identity control, and retain verified console or
independent SSH recovery access.

Source and license:
[IPverse country-ip-blocks](https://github.com/ipverse/country-ip-blocks),
CC0-1.0. Linux packages install the intact upstream legal text as
`/usr/share/doc/syswarden/GEOIP-DATA-LICENSE.txt`, and the embedded snapshot
binds the same license digest.

Blocklist selection remains independent of GeoIP:

- choices `1` and `2` select the standard and critical multi-origin Data-Shield
  feeds. They require quorum and do not require a custom SHA-256 value; leave
  all custom URL and hash fields empty;
- choice `3` is the custom mode. Configure at least one absolute HTTPS URL and
  pair every configured IPv4 or IPv6 URL with its exact SHA-256 digest;
- choice `4` disables the Data-Shield, custom and OSINT blocklist
  contributions.

The embedded GeoIP snapshot does not supply ASN policy. Configured ASN deny and
strict-allow selections remain operator-provisioned as described in the image
procedure below. Unsigned single-origin RADB and Spamhaus data is not accepted
as firewall authority; `update-feeds` reports that boundary and leaves any
existing operator-provisioned ASN files unchanged.

The historical public v4.02.8 binary predates the signed updater protocol. Its
historical first hop to v4.03.2 must use a separately downloaded and
checksum-verified Linux package.
Use the dedicated
[historical v4.02.8 to v4.03.2 migration runbook](https://syswarden.io/docs/migration-v4-02-8-to-v4-03-2/)
for the Debian 13 backup, provenance, acceptance and rollback sequence.
Use the separate
[version-specific v4.03.2 to v4.03.3 migration runbook](https://syswarden.io/docs/migration-v4-03-2-to-v4-03-3/) for the
four dynamic nftables set diagnosis, legacy interval removal, the recorded Ubuntu
26.04 OSINT installation gate and restricted rollback boundary.
After a qualified signed-protocol release is installed, `syswarden update`
verifies the canonical manifest, detached Ed25519 signature, platform identity,
package size and SHA-256 immediately before installation.

## 5. Optional RHEL-compatible image staging

The [RHEL 9+ image extensions](https://syswarden.io/docs/rhel-image-extensions/) page records the exact
availability boundary. Only the offline RPM staging extension described below
exists in v4.04.3.

The repository includes a separate, opt-in image-builder extension for an
extracted, fresh and unmounted RHEL-family 9 or newer root. It is not called by
the normal package installation, update or reload paths.

The image recipe must prepare all RPM dependencies first. The following RHEL 9
reference uses firewalld as the preserved frontend and prepares Cronie as the
required scheduler. The image root must be disposable and unmounted:

```bash
set -euo pipefail
IMAGE_ROOT=/srv/image-root
CANDIDATE_RPM=/srv/image-input/syswarden-4.04.3-1.x86_64.rpm
# Copy this value from the independently authenticated package inventory.
EXPECTED_RPM_SHA256=REPLACE_WITH_64_LOWERCASE_HEX_CHARACTERS

[[ "${EXPECTED_RPM_SHA256}" =~ ^[0-9a-f]{64}$ ]]
printf '%s  %s\n' "${EXPECTED_RPM_SHA256}" "${CANDIDATE_RPM}" | sha256sum --check --strict -
sudo extensions/rhel-image/stage-syswarden-rhel-image.sh \
  --preflight-root \
  --root "${IMAGE_ROOT}"

sudo dnf -y --installroot="${IMAGE_ROOT}" --releasever=9 \
  --setopt=install_weak_deps=False install \
  nftables ipset curl wget rsyslog cronie bash-completion \
  wireguard-tools jq checkpolicy policycoreutils-python-utils \
  dnf-automatic procps-ng e2fsprogs firewalld
sudo systemctl --root="${IMAGE_ROOT}" enable crond.service firewalld.service
sudo systemctl --root="${IMAGE_ROOT}" disable nftables.service
```

`qrencode` is optional on RHEL-family images. Install it only from an
operator-approved repository when terminal WireGuard QR rendering is required;
the protected client configuration file remains available without it.

The read-only `--preflight-root` call must precede `dnf` and
`systemctl --root`. It rejects `/`, a non-canonical or symlinked root, unsafe
ancestor directories, mounts below the image root and live runtime markers.
Stop on any failure. It performs no image or host mutation.

The image recipe may use repositories and service-manager tooling while it
prepares the tree. The extension itself does neither. Do not install or enable
`iptables-services` for this profile. Unmount every temporary image-builder
mount before invoking the extension.

Verify the exact candidate RPM and invoke the extension from the source tree:

```bash
set -euo pipefail
IMAGE_ROOT=/srv/image-root
CANDIDATE_RPM=/srv/image-input/syswarden-4.04.3-1.x86_64.rpm
# Copy this value from the independently authenticated package inventory.
EXPECTED_RPM_SHA256=REPLACE_WITH_64_LOWERCASE_HEX_CHARACTERS

[[ "${EXPECTED_RPM_SHA256}" =~ ^[0-9a-f]{64}$ ]]
printf '%s  %s\n' "${EXPECTED_RPM_SHA256}" "${CANDIDATE_RPM}" | sha256sum --check --strict -
sudo extensions/rhel-image/stage-syswarden-rhel-image.sh \
  --root "${IMAGE_ROOT}" \
  --rpm "${CANDIDATE_RPM}" \
  --sha256 "${EXPECTED_RPM_SHA256}"
```

Never derive the expected digest from the candidate RPM. Obtain it from the
separately authenticated package inventory.

The extension installs that RPM with plugins, package scripts and triggers
disabled. It does not enter the root or invoke product binaries, services,
firewall tools, kernel-policy tools, cron, network endpoints or process signals
during staging. A transaction journal permits bounded resume after an
interruption; altered or unknown state fails closed.

After the extension succeeds, prepare three image-owner source files below
`/srv/image-input/syswarden-config`. This step must follow staging because the
extension accepts only a fresh image at entry. Save this first block as
`config.toml` with owner `root:root` and mode `0640`:

```toml
schema_version = 1

[core]
config_dir = "/etc/syswarden/config/modules"
enterprise_mode = false
log_level = "INFO"
```

Save this block as `modules/00-core.toml` with owner `root:root` and mode
`0640`:

```toml
[core]
firewall_backend = "keep"
hardening_enabled = false
cis_l2_hardening = false
secure_wipe_conf = false
ssh_port = ""
```

Save this block as `modules/10-network.toml` with owner `root:root` and mode
`0640`:

<!-- syswarden-doc-toml-expect-invalid-asn -->
```toml
[network]
whitelist_infra = true
lan_subnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
whitelist_ips = []
interfaces = ""

[network.geo]
enabled = true
blocked_countries = ["ru", "cn", "kp", "ir"]
allowed_countries = []

[network.asn]
enabled = true
# This intentionally invalid value blocks first boot until it is replaced.
blocked_asns = ["REPLACE_WITH_APPROVED_HIGH_RISK_ASN"]
allowed_asns = []

[network.saas]
allow_monitors = false

[network.blocklists]
list_choice = "1"
custom_url = ""
custom_url_ipv6 = ""
custom_hash = ""
custom_hash_ipv6 = ""
use_spamhaus = false

[network.wireguard]
enabled = false
port = "51820"
subnet = ""
```

This profile selects standard blocklist choice `1`, automatic infrastructure
protection, explicit country and ASN deny sets, no remote monitor allowlisting,
and no WireGuard. The country values are a reviewable example, not a universal
traffic policy. Remove a country if the deployed services require it. Replace
the intentionally invalid ASN marker with the exact high-risk ASN values from
the image owner's current approved risk register. Until it is replaced,
configuration validation fails and the first-boot marker is retained. The
repository does not assign risk labels to network operators.

After replacing the ASN marker in the protected source file, publish all three
configuration files as one directory transaction from a trusted root shell:

```bash
set -euo pipefail
umask 077
if (( EUID != 0 )) || [[ "${GROUPS[0]}" != 0 ]]; then
  printf '%s\n' 'Run this complete configuration publication block as root.' >&2
  exit 1
fi
unset BASH_ENV ENV CDPATH GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH PYTHONPATH
PATH=/usr/bin:/bin
LC_ALL=C
export PATH LC_ALL
IMAGE_ROOT=/srv/image-root
CONFIG_SOURCE=/srv/image-input/syswarden-config
CONFIG_DESTINATION="${IMAGE_ROOT}/etc/syswarden/config"
CONFIG_STAGE="${IMAGE_ROOT}/etc/syswarden/.config.pending-v1"
CONFIG_FILES=(config.toml modules/00-core.toml modules/10-network.toml)

extensions/rhel-image/stage-syswarden-rhel-image.sh \
  --preflight-root \
  --root "${IMAGE_ROOT}"
for SOURCE_DIRECTORY in \
  /srv \
  /srv/image-input \
  "${CONFIG_SOURCE}" \
  "${CONFIG_SOURCE}/modules"; do
  [[ -d "${SOURCE_DIRECTORY}" && ! -L "${SOURCE_DIRECTORY}" ]]
  [[ "$(/usr/bin/stat -c '%u:%g' -- "${SOURCE_DIRECTORY}")" == "0:0" ]]
  SOURCE_MODE=$(/usr/bin/stat -c '%a' -- "${SOURCE_DIRECTORY}")
  (( (8#${SOURCE_MODE} & 8#022) == 0 ))
done
for CONFIG_FILE in "${CONFIG_FILES[@]}"; do
  SOURCE_FILE="${CONFIG_SOURCE}/${CONFIG_FILE}"
  [[ -f "${SOURCE_FILE}" && ! -L "${SOURCE_FILE}" ]]
  [[ "$(/usr/bin/stat -c '%u:%g:%a:%h' -- "${SOURCE_FILE}")" == "0:0:640:1" ]]
done

SYSWARDEN_PARENT="${IMAGE_ROOT}/etc/syswarden"
[[ ! -L "${SYSWARDEN_PARENT}" ]]
[[ ! -e "${SYSWARDEN_PARENT}" || -d "${SYSWARDEN_PARENT}" ]]
/usr/bin/install -d -o root -g root -m 0750 "${SYSWARDEN_PARENT}"
[[ ! -e "${CONFIG_DESTINATION}" && ! -L "${CONFIG_DESTINATION}" ]]
[[ ! -e "${CONFIG_STAGE}" && ! -L "${CONFIG_STAGE}" ]]
/usr/bin/install -d -o root -g root -m 0750 "${CONFIG_STAGE}/modules"
for CONFIG_FILE in "${CONFIG_FILES[@]}"; do
  /usr/bin/install -o root -g root -m 0640 \
    "${CONFIG_SOURCE}/${CONFIG_FILE}" "${CONFIG_STAGE}/${CONFIG_FILE}"
  /usr/bin/cmp --silent -- \
    "${CONFIG_SOURCE}/${CONFIG_FILE}" "${CONFIG_STAGE}/${CONFIG_FILE}"
done
/usr/bin/mv --no-clobber --no-target-directory \
  "${CONFIG_STAGE}" "${CONFIG_DESTINATION}"
[[ ! -e "${CONFIG_STAGE}" && -d "${CONFIG_DESTINATION}" && ! -L "${CONFIG_DESTINATION}" ]]
[[ "$(/usr/bin/stat -c '%u:%g:%a' -- "${CONFIG_DESTINATION}")" == "0:0:750" ]]
[[ "$(/usr/bin/stat -c '%u:%g:%a' -- "${CONFIG_DESTINATION}/modules")" == "0:0:750" ]]
for CONFIG_FILE in "${CONFIG_FILES[@]}"; do
  [[ "$(/usr/bin/stat -c '%u:%g:%a:%h' -- "${CONFIG_DESTINATION}/${CONFIG_FILE}")" == "0:0:640:1" ]]
  /usr/bin/cmp --silent -- \
    "${CONFIG_SOURCE}/${CONFIG_FILE}" "${CONFIG_DESTINATION}/${CONFIG_FILE}"
done
```

If this block fails or leaves a pending directory, discard and rebuild the
disposable image root.

The historical v4.04.0 change set was first published in the
historical v4.04.2 release and is carried forward in v4.04.3. It supplies the
configured country selections from its embedded release-bound snapshot,
including during image first boot.
When delivered through an official signed release, the signed manifest and
package digest authenticate the binary and its snapshot. A locally built or
otherwise unsigned binary provides content binding and structural validation,
not release authenticity. Do not stage country files in
`/etc/syswarden/lists`.

Every configured ASN still requires one non-empty canonical IPv4 file and one
non-empty canonical IPv6 file in `/etc/syswarden/lists`. For a blocked ASN, name
the pair `<AS-number>.ipv4` and `<AS-number>.ipv6`. For a strict-allow ASN, use
`allowed_<AS-number>.ipv4` and `allowed_<AS-number>.ipv6`. Replace the marker in
the example TOML before creating the blocked-ASN filenames. Generate the CIDRs
through an authenticated image-owner pipeline, verify an exact `SHA256SUMS`
manifest, and publish only regular non-symlink files as root with mode `0640`.
Each file contains one canonical IP or CIDR per line for the exact ASN and
address family. Runtime validation rejects missing or empty files, mixed-family
entries, default routes, IPv4 prefixes broader than `/24`, and IPv6 prefixes
broader than `/64` before nftables policy publication. The authenticated
image-owner pipeline must exclude private and other non-public ranges because
they do not establish ASN ownership.

Direct RADB or single-origin Spamhaus downloads are not authenticated authority
for ASN policy. Existing verified operator-provisioned files remain unchanged.

Publish the exact policy set as one exclusive transaction from a trusted root
shell. The block refuses partial per-command elevation. Replace the invalid ASN
in both the TOML and `APPROVED_ASNS` before running it:

```bash
set -euo pipefail
umask 077
if (( EUID != 0 )) || [[ "${GROUPS[0]}" != 0 ]]; then
  printf '%s\n' 'Run this complete policy publication block as root.' >&2
  exit 1
fi
unset BASH_ENV ENV CDPATH GLOBIGNORE LD_PRELOAD LD_LIBRARY_PATH PYTHONPATH
PATH=/usr/bin:/bin
LC_ALL=C
export PATH LC_ALL
IMAGE_ROOT=/srv/image-root
POLICY_SOURCE=/srv/image-input/policy-lists
extensions/rhel-image/stage-syswarden-rhel-image.sh \
  --preflight-root \
  --root "${IMAGE_ROOT}"
POLICY_DESTINATION="${IMAGE_ROOT}/etc/syswarden/lists"
POLICY_STAGE="${IMAGE_ROOT}/etc/syswarden/.lists.pending-v1"
# Copy this value from the authenticated image-owner policy inventory.
EXPECTED_POLICY_MANIFEST_SHA256=REPLACE_WITH_64_LOWERCASE_HEX_CHARACTERS
APPROVED_ASNS=(REPLACE_WITH_APPROVED_HIGH_RISK_ASN)

[[ "${EXPECTED_POLICY_MANIFEST_SHA256}" =~ ^[0-9a-f]{64}$ ]]
printf '%s  %s\n' "${EXPECTED_POLICY_MANIFEST_SHA256}" \
  "${POLICY_SOURCE}/SHA256SUMS" | /usr/bin/sha256sum --check --strict -

POLICY_FILES=()
for APPROVED_ASN in "${APPROVED_ASNS[@]}"; do
  [[ "${APPROVED_ASN}" =~ ^AS[1-9][0-9]{0,9}$ ]]
  ASN_NUMBER="${APPROVED_ASN#AS}"
  (( 10#${ASN_NUMBER} <= 4294967295 ))
  POLICY_FILES+=("${APPROVED_ASN}.ipv4" "${APPROVED_ASN}.ipv6")
done

mapfile -t MANIFEST_LINES < "${POLICY_SOURCE}/SHA256SUMS"
MANIFEST_FILES=()
declare -A EXPECTED_POLICY_SET=()
for POLICY_FILE in "${POLICY_FILES[@]}"; do
  [[ -z "${EXPECTED_POLICY_SET[${POLICY_FILE}]+x}" ]]
  EXPECTED_POLICY_SET["${POLICY_FILE}"]=1
done
declare -A MANIFEST_POLICY_SET=()
for MANIFEST_LINE in "${MANIFEST_LINES[@]}"; do
  [[ "${MANIFEST_LINE}" =~ ^[0-9a-f]{64}\ \ ([A-Za-z0-9]+\.(ipv4|ipv6))$ ]]
  MANIFEST_FILE="${BASH_REMATCH[1]}"
  [[ -z "${MANIFEST_POLICY_SET[${MANIFEST_FILE}]+x}" ]]
  MANIFEST_POLICY_SET["${MANIFEST_FILE}"]=1
  MANIFEST_FILES+=("${MANIFEST_FILE}")
done
(( ${#EXPECTED_POLICY_SET[@]} == ${#MANIFEST_POLICY_SET[@]} ))
for POLICY_FILE in "${POLICY_FILES[@]}"; do
  [[ -n "${MANIFEST_POLICY_SET[${POLICY_FILE}]+x}" ]]
done

(
  cd "${POLICY_SOURCE}"
  /usr/bin/sha256sum --check --strict SHA256SUMS
)
[[ ! -e "${POLICY_DESTINATION}" && ! -L "${POLICY_DESTINATION}" ]]
[[ ! -e "${POLICY_STAGE}" && ! -L "${POLICY_STAGE}" ]]
/usr/bin/install -d -o root -g root -m 0750 "${POLICY_STAGE}"
for POLICY_FILE in "${POLICY_FILES[@]}"; do
  [[ -f "${POLICY_SOURCE}/${POLICY_FILE}" && ! -L "${POLICY_SOURCE}/${POLICY_FILE}" ]]
  /usr/bin/install -o root -g root -m 0640 \
    "${POLICY_SOURCE}/${POLICY_FILE}" "${POLICY_STAGE}/${POLICY_FILE}"
done
/usr/bin/install -o root -g root -m 0640 \
  "${POLICY_SOURCE}/SHA256SUMS" "${POLICY_STAGE}/SHA256SUMS"
printf '%s  %s\n' "${EXPECTED_POLICY_MANIFEST_SHA256}" \
  "${POLICY_STAGE}/SHA256SUMS" | /usr/bin/sha256sum --check --strict -
(
  cd "${POLICY_STAGE}"
  /usr/bin/sha256sum --check --strict SHA256SUMS
)
/usr/bin/mv --no-clobber --no-target-directory \
  "${POLICY_STAGE}" "${POLICY_DESTINATION}"
[[ ! -e "${POLICY_STAGE}" && -d "${POLICY_DESTINATION}" && ! -L "${POLICY_DESTINATION}" ]]
printf '%s  %s\n' "${EXPECTED_POLICY_MANIFEST_SHA256}" \
  "${POLICY_DESTINATION}/SHA256SUMS" | /usr/bin/sha256sum --check --strict -
(
  cd "${POLICY_DESTINATION}"
  /usr/bin/sha256sum --check --strict SHA256SUMS
)
```

The block copies no glob and no unlisted input. If it fails or leaves a pending
directory, discard and rebuild the disposable image root.

The extension enables a marker-guarded unit that requires `crond.service`. On
the real first boot, the normal `install` and `reload` workflows validate the
configuration, prove a live enabled Cronie provider, preserve the active
firewalld frontend through `keep`, and converge runtime state. nftables remains
the authoritative SysWarden policy engine.

Run the image builder's normal SELinux relabel, ownership, bootloader, package
inventory and ISO assembly steps. Boot a disposable target and verify:

```console
sudo systemctl is-active crond.service firewalld.service
sudo systemctl is-enabled crond.service firewalld.service
sudo systemctl status syswarden-image-firstboot.service
sudo journalctl -u syswarden-image-firstboot.service --no-pager
sudo syswarden config validate --path /etc/syswarden/config
sudo nft -j list table inet syswarden
sudo test ! -e /var/lib/syswarden/image/firstboot.pending
```

Confirm recovery access and expected business traffic before reusing the image
recipe. A failed first-boot unit retains its marker and retries on the next
boot; correct the cause rather than deleting the marker.

Offline staging is not runtime-readiness evidence. ISO assembly, signing,
bootloader integration and target-host validation remain separate image-builder
responsibilities. See the repository extension README for the complete boundary
and recovery contract.

## 6. Safe upgrade offboarding

The current product has no network terminal service. Upgrade qualification must
prove that verified historical SysWarden-owned listener, credential, browser
asset and service state is removed while unrelated configuration and unrelated
processes are preserved.

After upgrade:

1. confirm the package owns no TCP 62027 listener or firewall permission;
2. confirm only the native local TUI entry point remains;
3. confirm no remote PTY, browser asset or token-management route remains;
4. confirm an unrelated process using the same numeric port was not stopped;
5. confirm unrelated configuration was not deleted;
6. confirm the core, authoritative nftables policy and selected firewall service
   state pass their normal health checks.

A downgrade is an emergency operation and can restore an older network surface.
Keep TCP 62027 blocked at the host boundary and do not restore retired
authentication material.

## 7. Configuration layout

The modular root is `/etc/syswarden/config`. Later module filenames have higher
precedence. Back up the complete directory, validate the candidate and inspect
the effective diff before applying it.

`schema_version = 1` is the current modular schema. When `schema_version` is
absent, the configuration is treated as historical input. An explicit future,
negative or non-integer schema version fails closed.

```console
sudo syswarden config validate --path /etc/syswarden/config
sudo syswarden config
sudo syswarden reload
```

`syswarden config validate` is read-only. All unknown and deprecated keys are
reported as diagnostics; a semantic or structural violation still fails
validation. The CLI validator and core loader enforce the same semantic matrix,
including SSH/HA port separation while WireGuard is enabled.

### Firewall service selection

`core.firewall_backend` accepts `keep`, `nftables` or `iptables`:

- `keep` is the default and performs no firewall service transition, but
  refuses policy mutation while an iptables-services or netfilter-persistent
  service is active or enabled;
- `nftables` validates that `nftables.service` is already active and enabled
  and refuses any active or enabled firewalld, UFW, iptables-services or
  netfilter-persistent frontend;
- `iptables` remains parseable for configuration compatibility, but operational
  firewall policy mutation paths reject it in v4.04.3 before changing
  persistent policy inputs or kernel firewall state.

Operational policy mutation requires an active, unambiguous supported service
manager. Package hooks running with no service-manager runtime defer `install`
and `reload` instead of invoking host firewall or kernel tools.

The packaged candidate always validates and commits the authoritative
SysWarden nftables policy. If exactly one supported firewalld or UFW frontend is
already active, SysWarden may reconcile only bounded owned compatibility rules.
Installed but inactive frontends are not activated. Automatic service migration
is outside the qualified v4.04.3 contract. Prepare and verify any required
service change through an independent recovery procedure before selecting
`nftables`, then run `syswarden reload` from a verified recovery session.

Native host and CIDR mutations in nftables interval sets use a start element and
the exact exclusive end marker required by the kernel. The manager repairs a
detected unterminated start before replacement and rejects ambiguous state or an
interval containing internal boundaries. An exclusive end marker left alone
after timed-start expiry is treated as functionally absent; a later re-ban must
install and verify one complete pair. Unit tests are supporting evidence only.
The protected release still requires add, timed renewal, permanent replacement,
replay and removal for both address families in the isolated real-kernel
nftables laboratory.

WireGuard requires the explicit `nftables` backend. The bounded firewalld and
UFW compatibility path does not open the WireGuard UDP port or forwarding
rules. Active firewalld and UFW target-host execution remains outside the
qualified evidence.

### Recover exact historical WireGuard state

**Availability:** The following is a version-specific v4.10.0 source procedure.
The public stable CLI does not provide `recover-wireguard`. Do not run these
commands on that release or infer availability from this page's stable
baseline. The candidate implementation remains subject to release validation
under the [IVV/IVVQ strategy](https://syswarden.io/docs/release-assurance/).

An upgrade can encounter an unmarked `inet syswarden_wg` table created by an
older SysWarden generation. Reload and uninstall remain fail-closed when the
table cannot be proven against the current ownership manifest. Do not delete
the table or shared forwarding rules manually.

Keep verified console access, stop and disable the historical WireGuard
service, and confirm that its interface is absent. Then generate a read-only,
secret-redacted recovery plan:

```console
sudo syswarden recover-wireguard
```

The command accepts only the exact historical pre-v3.75.7 `wg0` generation or
the exact historical v3.75.7 through historical v4.03.0 `wg-syswarden`
generation. It refuses unknown tables,
changed configuration, active services, present interfaces, duplicate rules
and ambiguous ownership. Review the JSON plan and its SHA-256 digest. Apply
only that exact plan with the digest printed by the dry run:

```console
sudo syswarden recover-wireguard --apply --plan-sha256 <exact-sha256>
```

The apply phase reattests the host state immediately before the bounded
nftables transaction and verifies that the historical state is absent. If any
evidence changes, generate and review a new plan. Run `syswarden reload` only
after recovery succeeds.

Use `syswarden config migrate` for the structured migration path. Preview it
with `--dry-run` before publishing any file. `syswarden config migrate --dry-run`
performs zero source or destination writes, including when a previous migration
transaction requires recovery. `syswarden migrate-config` remains a
compatibility alias with the same transactional and dry-run contract.

A historical `SYSWARDEN_FIREWALL_BACKEND="firewalld"` value migrates to
`core.firewall_backend = "keep"`, preserving the existing service without an
automatic transition. A historical configuration that enables WireGuard with
any backend other than `nftables` is rejected for explicit operator remediation
rather than silently changing the backend choice.

The official SaaS monitor setting is disabled unless explicitly enabled:

```toml
[network.saas]
allow_monitors = false
```

`network.saas.allow_monitors` takes precedence over the deprecated
`integrations.saas.enabled` alias; if neither is set, monitor allowlisting is
disabled. Enabled monitor feeds must use absolute HTTPS URLs without
credentials or fragments. TLS 1.3 is required, redirects are rejected and each
download is bounded to 10 seconds, 1 MiB, 1,024 bytes per line, 20,000 lines
and 10,000 entries. A required-feed, syntax or bound failure preserves the
previous lists. Valid canonical IPv4 and IPv6 results are published as one
lock-coordinated atomic pair with an SHA-256 pair manifest and rollback on
publication failure.

WAAP log settings use canonical single-space-separated absolute patterns.
Configured globs are reduced to verified exact real regular-file matches. The
core opens them descriptor-relative without following links and revalidates the
file identity and type after every rotation; it never creates a missing target.
Rsyslog inputs are emitted only for paths that pass the same validation at
configuration time, but rsyslog later reopens those names itself, so operators
must keep every parent directory protected against untrusted replacement.
Rsyslog strings and WireGuard values are validated and encoded for their
destination grammars before configuration publication.

### Persistent list grammar

Blocklist, whitelist and SSH-exception values use exact canonical IPv4 or IPv6
addresses and masked CIDRs. Hostnames, address zones, IPv4-mapped IPv6 values,
invalid ports, control characters and ambiguous substring matches are rejected.
The address family must match the destination list.

Use the explicit flag to limit a whitelist entry to one TCP service:

```console
sudo syswarden whitelist 192.0.2.10 --port 443
```

`--port` scopes a whitelist entry to one TCP service; omitting it creates an
address-wide whitelist entry. SSH exceptions are rendered only for the
effective SSH port. A port-qualified SSH entry must equal that effective port,
and a changed-port mismatch fails closed before candidate policy application.
Reconcile the SSH exception registry from verified console access before
applying an SSH port change.

Minimal authenticated HA configuration:

```toml
[integrations.ha]
enabled = true
peer_ips = ["192.0.2.10", "192.0.2.11"]
peer_port = 62026
token = "replace-with-a-secret-from-a-protected-channel"

[integrations.bunkerweb]
enabled = true
```

Do not commit a real token. Exact peer IPs may be dialed. Canonical CIDRs
authorize inbound peers only and are never outbound destinations. If
`/etc/syswarden/ha-ca.pem` exists, clients use it as the exclusive HA trust
pool; otherwise they use system trust roots.

## 8. User interfaces and ports

Launch the dashboard from a trusted local console or SSH terminal:

```console
sudo syswarden tui
```

The TUI reads local telemetry and HA status in the invoking terminal. It opens
no listening socket. SysWarden owns no listener or generated firewall permission
on TCP 62027.

The authenticated HA API uses TLS 1.3 and configurable TCP port 62026 by
default. Every HA route requires the bearer token. Configuration validation
rejects an empty token or any token containing whitespace or control characters
before reload mutation or service restart. The previously validated
configuration and its running listener remain active until a valid candidate is
successfully reloaded.

## 9. HA dialect and ownership

For each peer and serialized synchronization cycle, use one authenticated
`GET /ha/status` to select the dialect. Enriched operations require both
`sync_ttl` and `sync_provenance`. Missing, partial, malformed or failed
capabilities stop the handoff for that peer and never relax TLS or bearer
authentication.

The ownership stores are intentionally separate:

- `DELETE {"bans": [...]}` removes provenance ledger entries only;
- `DELETE {"ips": [...]}` explicitly removes historical static entries;
- neither delete cascades to another peer;
- the integrator cleans only addresses present in its durable, peer-specific
  record of historical submissions;
- ownership is never inferred from `GET /ha/sync`, provenance pagination or an
  effective union returned by a peer.

The enriched DELETE contract introduced by the historical v4.04.0 change set
was first published in the historical v4.04.2 release and is carried forward in
v4.04.3. After successful processing, it returns
`{"status":"ok","deleted":N}`. `deleted` is the number of exact provenance claims
removed for the canonical IP, `source` and authenticated peer scope. It is not
a count of distinct IP addresses and is not proof that
nftables unblocked the address, because another owner or a historical static
claim can keep it banned. A source or scope mismatch is a safe idempotent no-op
with `deleted:0`.

## 10. Cluster migration fence

The operator, not the integrator, supplies the complete member and external
legacy-writer inventory. One trusted control host creates one canonical
activation manifest and distributes the same protected file to every member and
to the integrator:

```json
{
  "schema_version": 1,
  "membership_scope": "one_receiving_api_endpoint_per_syswarden_node",
  "legacy_writer_ids": [
    "bunkerweb-primary"
  ],
  "members": [
    {
      "address": "192.0.2.10",
      "port": 62026
    }
  ]
}
```

Replace the documentation address with each receiving member's canonical IP
literal. The inventory must be an absolute canonical path to a regular,
root-owned mode `0600` file no larger than 1 MiB under a protected directory
chain. JSON is strict: no byte-order mark, duplicate or unknown keys, or
trailing data. At least one unique address and port endpoint is required.
Addresses cannot be CIDRs, zoned, bracketed or IPv4-mapped IPv6 values, and
ports range from 1 through 65535. `legacy_writer_ids` is mandatory but may be
empty. Non-empty IDs match `[a-z0-9][a-z0-9._-]{0,63}` and are unique.
`--assert-complete` attests that the member and legacy-writer inventories are
complete.

```console
sudo syswarden ha-fence manifest create \
  --inventory /root/syswarden-ha-inventory.json \
  --output /root/syswarden-ha-manifest.json \
  --assert-complete
sudo syswarden ha-fence manifest verify \
  --manifest /root/syswarden-ha-manifest.json
sudo syswarden ha-fence engage \
  --manifest /root/syswarden-ha-manifest.json
sudo syswarden ha-fence status --json
```

The capability `native_sync_fence_v1` means only that a node understands the
schema. The proof is the fresh, dynamic and challenge-bound
`native_sync_fence` object returned by the exact member over the verified TLS
connection.

The integrator accepts proof only when `state` is `active_drained`, challenge,
leaf identity, server instance, generation and condition are stable, and the
following operator-manifest values match exactly:

- `epoch`;
- `membership_sha256`;
- `legacy_writer_inventory_sha256`.

These values are opaque, case-sensitive strings. The integrator compares exact
strings and does not recalculate digests or reimplement canonicalization. The
response header `X-SysWarden-HA-Fence-Condition` must equal the JSON `condition`
value from the same response.

Every historical static cleanup request sends the observed condition in
`X-SysWarden-HA-Fence-Condition`. A missing condition receives HTTP 428, a
malformed condition receives HTTP 400 and a stale condition receives HTTP 412.
All three reject without mutation. HTTP 412 means that the fence changed; stop,
refresh the complete all-member proof and require an operator decision.

An unreachable member, changed inventory, changed certificate, restarted server
instance, changed generation, blind interval or reappearing address resets the
observation. A one-hour continuous absence window across every declared peer is
supporting evidence only. It is never proof of drain and never permits claim
release from a partial view.

Fence release requires the unchanged manifest and durable terminal closure
evidence for every declared external writer:

```json
{
  "schema_version": 1,
  "epoch": "COPY_EXACT_EPOCH_FROM_MANIFEST",
  "membership_sha256": "COPY_EXACT_MEMBERSHIP_SHA256_FROM_MANIFEST",
  "legacy_writer_inventory_sha256": "COPY_EXACT_WRITER_SHA256_FROM_MANIFEST",
  "legacy_retry_queue_drained": true,
  "writers": [
    {
      "id": "bunkerweb-primary",
      "disposition": "migrated_enriched_only",
      "closure_generation": "REPLACE_WITH_DURABLE_GENERATION",
      "closed_at": "REPLACE_WITH_CANONICAL_UTC_RFC3339",
      "evidence_sha256": "REPLACE_WITH_LOWERCASE_EVIDENCE_SHA256"
    }
  ]
}
```

Replace every placeholder with values bound to the unchanged manifest and
durable closure evidence. The writer closure follows the same protected-file
rules and must use the canonical indented layout shown with one final newline.
Its writers exactly match the manifest writer IDs in canonical order; use an
empty array when the manifest contains no writers. The retry queue flag is
`true`. Terminal dispositions are `migrated_enriched_only`, `disabled`,
`credential_revoked` or `network_quarantined`. The generation is 1 through 256
non-space printable ASCII characters, the closure time is canonical whole-second
UTC RFC3339, and the evidence digest is lowercase SHA-256.

```console
sudo syswarden ha-fence release \
  --manifest /root/syswarden-ha-manifest.json \
  --writer-closure /root/syswarden-ha-writer-closure.json
```

Before the historical v4.03.2 freeze, the BunkerWeb partner had to provide
written confirmation against the exact frozen contract. No unexplained
partner-attributable static residue may remain.

See [BunkerWeb integration](https://syswarden.io/docs/bunkerweb-integration/) for the full integrator
contract.

## 11. Command inventory

The table lists product commands only. Cobra help and shell-completion utilities
remain available separately. Unmarked rows belong to the stable baseline.
Rows marked "source candidate only" belong to the version-specific v4.10.0
source at commit `8fc22a6bae4adfcf62d80e90da6387f2aa6db1c7`; they are absent
from the stable release and are not a release qualification claim.

| Command | Purpose |
|---|---|
| `alerts` | Stream kernel and WAAP alert events |
| `allow-ssh` | Add an address to the SSH exception registry |
| `audit` | Run a bounded local operational diagnostic |
| `block` | Add addresses or CIDRs to the persistent blocklist |
| `check` | Inspect recorded firewall state for an address |
| `config` | Validate, inspect and migrate modular configuration |
| `config-get` | Read one effective configuration key |
| `ha-fence` | Administer a cluster migration fence |
| `ha-sync` | Push missing local durable entries to exact peers |
| `install` | Run the host-mutating installation pipeline |
| `list` | Display local registries and active HA bans |
| `manual` | Display the built-in operator reference |
| `migrate-config` | Run the compatible configuration migration entry point |
| `reload` | Reapply policy and normally restart the core |
| `recover-wireguard` | Source candidate only: inspect and explicitly recover exact historical WireGuard nftables state |
| `revoke-ssh` | Remove an address from the SSH exception registry |
| `runtime-unblock` | Source candidate only: remove local runtime claims through the authenticated core; persistent blocklists and independent HA claims remain effective |
| `tui` | Launch the native local terminal dashboard |
| `unblock` | Remove addresses or CIDRs from the blocklist |
| `uninstall` | Delete SysWarden services, rules, configuration, data and logs |
| `unwhitelist` | Remove addresses or CIDRs from the whitelist |
| `update` | Install an update verified by the signed manifest |
| `update-feeds` | Validate embedded GeoIP, refresh external feeds and reapply policy |
| `whitelist` | Add addresses or CIDRs to the persistent whitelist |
| `whitelist-infra` | Detect and add local infrastructure addresses |

Use `syswarden <command> --help` before any host-mutating action.

## 12. Audit and removal

`syswarden audit` is a bounded local diagnostic. It does not certify compliance.
`syswarden uninstall` deletes SysWarden configuration, data, logs, services and
firewall state. It is not a general host rollback. Back up required material,
retain console access and verify unrelated host state before removal.

Removal intentionally does not reverse successfully applied OS or CIS
hardening. Exact hardening policy files may therefore remain after the product
runtime is gone. On a Fedora or RHEL-family host where SysWarden installed and
configured `dnf-automatic`, dependency cleanup may also preserve the hardened
configuration as `/etc/dnf/automatic.conf.rpmsave`. Treat these files as host
security policy, not active SysWarden runtime. Review their contents and package
provenance from console access before deciding whether to keep or retire them;
do not delete them merely to make a residue scan empty.

Continue with the [bounded use cases](https://syswarden.io/docs/use-cases/) before production rollout.
