MintOS - Post Script - Updated

MintOS - Post Script - Updated
Linux MIntOS

Updated: 28-07-2026

The following code is shared.

The script was then reengineered back to MintOS with a few changes

  1. MintOS is my daily driver
  2. MintOS has advantages for me that BazziteOS doesnt accommodate easily
  3. This code has numerous changes to accommodate my needs
  4. Clam AV hardening is not in this script - but is to be rewritten shortly

Post MintOS installation script

The following script is based on the followin

  1. Based on learning.
  2. Reduce the size of the script - its getting to large and standardise. Looking at this script you should be able to look at the say, bazzite OS script and the sections match.
  3. Script has evolved from Debian 13.0 to Debian 13.6.
  4. Cut down on the endless comments - simplify.
  5. Set more variables - simplify - more generic.
  6. Continue to test and evolve.
#!/bin/bash

# ================================================================================
# Base Setup Script for Linux Mint (LMDE 7 / Ubuntu Edition)
# Filename : setup-laptop-mintos.sh
# Updated  : 2026-07-28
# Version  : 3.7.1
# ================================================================================
#
# Post-install hardening for Linux Mint laptops. Auto-detects LMDE (Debian
# base) vs Ubuntu-based Mint at runtime; aborts on any other OS.
#
# IDEMPOTENT BY DESIGN -- safe to re-run any number of times. One exception:
# SSH host keys (Section 8) rotate on every run by design (old keys backed
# up, never deleted). That is intentional, not a bug -- do not "fix" it.
#
# WHAT IT DOES: AppArmor, UFW (default-deny + SSH/WireGuard/mDNS/print-share
# rules), hardened SSH drop-in, ClamAV, an optional trusted cert (CERT_URL),
# package cleanup + base tools, Insync, Flatpak apps, fwupd, unattended
# upgrades, MOTD, and a diagnostics pass.
#
# SUPPORTED: LMDE 7 (Debian Trixie) and Ubuntu-based Mint 22.x (Ubuntu 24.04).
# Debian 13 Trixie ships OpenSSH 10.0p1; the sshd drop-in avoids every
# removed/deprecated directive so it's clean on both OpenSSH 9.x and 10.x.
#
# CHANGELOG:
#   3.7.1  2026-07-28  Comments trimmed to one-line "why" notes; vendor/brand
#                      names genericized.
#   3.7.0  2026-07-28  Added a USER CONFIGURATION block so nothing is
#                      hardcoded (LAN range, cert URL, DNS test host, user).
#   3.6.0  2026-07-28  Fixed OS-base misdetection and Insync codename
#                      derivation on both branches; added GSSAPI hardening;
#                      guarded the dig-based DNS check.
#   3.5.0  2026-07-14  mDNS now accepts unicast/cross-VLAN replies; added the
#                      ok() helper, hard idempotency note, OpenSSH 10 note.
#   3.4.0  2026-07-14  fwupd reworked (VM-skip, locale-proof, UEFI gating);
#                      more Flatpak apps; synced version banners.
#   3.3.0  2026-05-30  Fixed CERT_URL typo and inverted firmware logic;
#                      dropped resolvconf; modernized Insync GPG handling.
#   3.0.4  2026-05-26  Added UFW printer rules; moved VSCode to Flatpak; cert
#                      DER->PEM fallback.
#   3.0.0  2026-05-17  Initial release (ported from setup-laptop-bazzite.sh).
#
# ================================================================================

set -euo pipefail

# ================================================================================
# Privilege check
# ================================================================================

if [[ "${EUID}" -ne 0 ]]; then
    echo "[ERROR] This script must be run as root (sudo ./setup-laptop-mintos.sh)"
    exit 1
fi

# ================================================================================
# Logging and helpers
# ================================================================================

LOGFILE="/var/log/mintos-setup.log"
BACKUP_TS="$(date +%Y%m%d-%H%M%S)"

exec > >(tee -a "${LOGFILE}") 2>&1

info()  { echo -e "\033[0;32m[INFO]\033[0m  $*"; }
warn()  { echo -e "\033[0;33m[WARN]\033[0m  $*" >&2; }
error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; exit 1; }
step()  { echo -e "\n\033[1;34m[====]\033[0m $*\n"; }
ok()    { echo -e "\033[0;32m[ OK ]\033[0m  $*"; }

info "MintOS setup v3.7.1 started at $(date)"
info "Log file: ${LOGFILE}"

# ================================================================================
# USER CONFIGURATION -- edit for your environment before running
# ================================================================================
LAN_CIDR="192.168.0.0/16"   # your LAN range (SSH/print-share rules scope to this)
CERT_URL=""                 # URL to a block-page/CA cert to trust, if you have one
DNS_TEST_HOST="example.com" # host used for the DNS sanity check in Diagnostics
TARGET_USER=""              # username for the optional user-specific software block

# ================================================================================
# OS detection
# ================================================================================

[[ -f /etc/os-release ]] || error "/etc/os-release not found -- cannot determine OS."

source /etc/os-release

OS_ID="${ID:-unknown}"
OS_ID_LIKE="${ID_LIKE:-unknown}"
OS_PRETTY="${PRETTY_NAME:-unknown}"
OS_CODENAME="${VERSION_CODENAME:-unknown}"

info "Detected OS: ${OS_PRETTY}"
info "ID: ${OS_ID}  |  ID_LIKE: ${OS_ID_LIKE}  |  Codename: ${OS_CODENAME}"

[[ "${OS_ID}" == "linuxmint" ]] \
    || error "Unsupported OS: '${OS_ID}'. This script requires Linux Mint."

# UBUNTU_CODENAME is unambiguous either way; ID_LIKE alone is not (Ubuntu-based
# Mint 22.x sets ID_LIKE="ubuntu debian", so a debian-first grep misclassifies it).
UBUNTU_CODENAME="${UBUNTU_CODENAME:-}"
if [[ -n "${UBUNTU_CODENAME}" ]]; then
    MINT_BASE="ubuntu"
elif echo "${OS_ID_LIKE}" | grep -qiw "debian"; then
    MINT_BASE="debian"
else
    error "Mint base '${OS_ID_LIKE}' not recognised. Expected 'debian' or 'ubuntu'."
fi

info "Mint base: ${MINT_BASE}"

# ================================================================================
# User context
# ================================================================================

CURRENT_USER=$(logname 2>/dev/null || echo "${SUDO_USER:-root}")
CURRENT_HOME=$(getent passwd "${CURRENT_USER}" | cut -d: -f6)
info "User context: ${CURRENT_USER} (home: ${CURRENT_HOME})"

# ================================================================================
# Pre-flight: network and systemd
# ================================================================================

step "Pre-flight"

info "Checking network connectivity..."
apt-get update -qq 2>/dev/null || error "apt-get update failed -- check DNS/network."
info "Network OK."

info "Resetting failed systemd units..."
systemctl reset-failed || true

# ================================================================================
# SECTION 1: Package Management
# ================================================================================

step "Section 1: Package Management"

info "Holding bluetooth package..."
apt-mark hold bluetooth 2>/dev/null || warn "bluetooth package not found -- skipping hold."

info "Purging unwanted packages..."
apt-get purge -y --ignore-missing \
    firefox \
    firefox-esr \
    firefox-locale-en \
    inetutils-telnet \
    'libreoffice-*' \
    'libreoffice*' \
    || true
apt-get autoremove --purge -y
apt-get autoclean -y
apt-get clean -y

info "Installing base packages..."
apt-get install -y -qq \
    curl \
    wget \
    gnupg \
    nmap \
    mtr \
    smartmontools \
    gsmartcontrol \
    nvme-cli \
    bind9-dnsutils \
    ffmpeg \
    rsyslog \
    fwupd \
    openresolv \
    openssh-server \
    openssh-client \
    cups \
    avahi-daemon \
    unattended-upgrades \
    apt-listchanges \
    || error "Base package installation failed."

# 'enable --now' is idempotent -- a no-op if already active.
info "Enabling Avahi (mDNS/DNS-SD) and CUPS..."
systemctl enable --now avahi-daemon \
    || warn "avahi-daemon failed to enable -- mDNS printer discovery will not work."
systemctl enable --now cups \
    || warn "cups failed to enable -- printing will not work."

# ================================================================================
# SECTION 2: WireGuard VPN
# ================================================================================

step "Section 2: WireGuard VPN"

info "Installing WireGuard..."
apt-get install -y --no-install-recommends \
    wireguard \
    wireguard-tools \
    iptables \
    iproute2 \
    || warn "WireGuard installation failed -- kernel module may still be available."

info "WireGuard installed. Configure via /etc/wireguard/wg0.conf or Settings > Network > VPN."

# ================================================================================
# SECTION 3: UFW Firewall
# ================================================================================

step "Section 3: UFW Firewall"

info "Installing UFW..."
apt-get install -y ufw || error "UFW installation failed."

info "Setting defaults: deny inbound, allow outbound..."
ufw --force reset
ufw default deny incoming
ufw default allow outgoing

ufw allow in proto tcp to any port 22 \
    comment "SSH inbound"

# Client-only; egress covers outbound WireGuard. Uncomment to accept inbound:
# ufw allow in proto udp to any port 51820 comment "WireGuard inbound"
ufw allow out proto udp to any port 51820 \
    comment "WireGuard egress"

# Only needed if this host shares its own CUPS queues -- a print client's
# replies ride the existing connection, so these are harmless otherwise.
ufw allow in proto tcp from "${LAN_CIDR}" to any port 631 \
    comment "IPP - only used if this host shares CUPS"
ufw allow in proto tcp from "${LAN_CIDR}" to any port 443 \
    comment "IPPS - only used if this host shares CUPS"
ufw allow in proto tcp from "${LAN_CIDR}" to any port 9100 \
    comment "raw/JetDirect - only used if this host shares CUPS"

# Matched by port, not multicast dest, and broadened past LAN-only: catches
# unicast mDNS replies and anything forwarded in from another VLAN by a
# network's mDNS reflector.
MDNS_SOURCES=(
    "10.0.0.0/8"
    "172.16.0.0/12"
    "${LAN_CIDR}"
    "169.254.0.0/16"
    "fe80::/10"
    "fc00::/7"
)
for _mdns_src in "${MDNS_SOURCES[@]}"; do
    ufw allow in proto udp from "${_mdns_src}" to any port 5353 \
        comment "mDNS/DNS-SD from ${_mdns_src}"
done

info "Enabling UFW..."
ufw --force enable

info "UFW status:"
ufw status verbose

# ================================================================================
# SECTION 4: AppArmor
# ================================================================================

step "Section 4: AppArmor"

info "Installing AppArmor packages..."
apt-get install -y \
    apparmor \
    apparmor-utils \
    apparmor-profiles \
    apparmor-profiles-extra \
    || error "AppArmor installation failed."

if [[ "${MINT_BASE}" == "debian" ]]; then
    info "LMDE base: enabling and starting AppArmor service..."
    systemctl enable apparmor || warn "Failed to enable AppArmor."
    systemctl start  apparmor || warn "Failed to start AppArmor."
else
    info "Ubuntu base: AppArmor is kernel-integrated -- verifying status..."
    systemctl is-active --quiet apparmor \
        && info "AppArmor is active." \
        || warn "AppArmor service not active -- a reboot may be required."
fi

if aa-enabled 2>/dev/null; then
    info "AppArmor is enabled."
    aa-status 2>/dev/null | head -5 || true
else
    warn "AppArmor may not be fully active. A reboot may be required."
fi

# ================================================================================
# SECTION 5: Insync (Google Drive / OneDrive client)
# ================================================================================

step "Section 5: Insync"

INSYNC_GPG_DST="/usr/share/keyrings/insynchq.gpg"
INSYNC_LIST="/etc/apt/sources.list.d/insync.list"
INSYNC_GPG_URL="https://apt.insync.io/insynchq.gpg"

info "Adding Insync GPG key..."
if curl -fsSL "${INSYNC_GPG_URL}" | gpg --dearmor | tee "${INSYNC_GPG_DST}" > /dev/null; then
    info "Insync GPG key installed to ${INSYNC_GPG_DST}"
else
    warn "Failed to download Insync GPG key -- Insync install may fail."
fi

if [[ "${MINT_BASE}" == "debian" ]]; then
    INSYNC_DISTRO="debian"
    # LMDE's VERSION_CODENAME is its own release name, not Debian's -- derive
    # the real Debian codename from /etc/debian_version instead.
    DEBIAN_MAJOR="$(cut -d. -f1 /etc/debian_version 2>/dev/null || echo unknown)"
    case "${DEBIAN_MAJOR}" in
        12) INSYNC_CODENAME="bookworm" ;;
        13) INSYNC_CODENAME="trixie"   ;;
        14) INSYNC_CODENAME="forky"    ;;
        *)  INSYNC_CODENAME="trixie"
            warn "Unrecognised /etc/debian_version '${DEBIAN_MAJOR}' -- defaulting to trixie." ;;
    esac
else
    INSYNC_DISTRO="ubuntu"
    # Reading UBUNTU_CODENAME directly avoids a hardcoded Mint->Ubuntu map
    # that goes stale (and is easy to mis-group) every Mint release.
    if [[ -n "${UBUNTU_CODENAME}" ]]; then
        INSYNC_CODENAME="${UBUNTU_CODENAME}"
    else
        INSYNC_CODENAME="noble"
        warn "UBUNTU_CODENAME not set in os-release -- defaulting to noble."
    fi
fi

info "Configuring Insync repo (${INSYNC_DISTRO} / ${INSYNC_CODENAME})..."
echo "deb [signed-by=${INSYNC_GPG_DST}] https://apt.insync.io/${INSYNC_DISTRO} ${INSYNC_CODENAME} non-free contrib" \
    > "${INSYNC_LIST}"

apt-get update -qq || warn "apt-get update after Insync repo addition failed."

info "Installing Insync and Nemo integration..."
apt-get install -y insync insync-nemo \
    || warn "Insync install failed -- install manually: sudo apt install insync insync-nemo"

# ================================================================================
# SECTION 6: Flatpak Applications
# ================================================================================

step "Section 6: Flatpak Applications"

info "Ensuring Flathub remote is present..."
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo || true

FLATPAK_APPS=(
    "org.gimp.GIMP"
    "org.inkscape.Inkscape"
    "org.kde.krita"
    "org.libreoffice.LibreOffice"
    "org.videolan.VLC"
    "com.github.PintaProject.Pinta"
    "com.google.Chrome"
    "org.kde.kdenlive"
    "org.blender.Blender"
    "com.discordapp.Discord"
    "com.fogpanther.FogPanther"
    "org.winehq.Wine"
)

for app in "${FLATPAK_APPS[@]}"; do
    info "Installing Flatpak: ${app}..."
    if flatpak install -y --noninteractive flathub "${app}" 2>/dev/null; then
        info "  + ${app}"
    else
        warn "  - ${app} -- install failed or already installed."
    fi
done

# ================================================================================
# SECTION 7: ClamAV
# ================================================================================

step "Section 7: ClamAV"

info "Installing ClamAV..."
apt-get install -y clamav clamav-daemon clamtk || error "ClamAV installation failed."

info "Updating virus definitions..."
systemctl stop clamav-freshclam 2>/dev/null || true
freshclam || warn "ClamAV database update failed -- will retry on next scheduled run."
systemctl enable --now clamav-freshclam || warn "clamav-freshclam failed to start."

systemctl is-active --quiet clamav-daemon \
    && info "clamav-daemon is running." \
    || warn "clamav-daemon not running -- run: sudo systemctl start clamav-daemon"

# ================================================================================
# SECTION 8: SSH Hardening
# ================================================================================

step "Section 8: SSH Hardening"

info "[CRITICAL] Hardening SSH..."
SSHD_DIR="/etc/ssh"
SSHD_CFG="${SSHD_DIR}/sshd_config"
SSHD_CUSTOM_DIR="${SSHD_DIR}/sshd_config.d"
SSHD_CUSTOM_CFG="${SSHD_CUSTOM_DIR}/99-hardened-custom.conf"
BACKUP_DIR="${SSHD_DIR}/backup-${BACKUP_TS}"
mkdir -p "${BACKUP_DIR}" "${SSHD_CUSTOM_DIR}"

cp -a "${SSHD_CFG}" "${BACKUP_DIR}/sshd_config.bak" || true
for key in ssh_host_ed25519_key ssh_host_rsa_key ssh_host_ecdsa_key; do
    [[ -f "${SSHD_DIR}/${key}" ]]     && mv "${SSHD_DIR}/${key}"     "${BACKUP_DIR}/" || true
    [[ -f "${SSHD_DIR}/${key}.pub" ]] && mv "${SSHD_DIR}/${key}.pub" "${BACKUP_DIR}/" || true
done

# Rotates every run by design (see header) -- do not guard into idempotency.
info "Generating new SSH host keys..."
rm -f /etc/ssh/ssh_host_*
ssh-keygen -t ed25519 -f "${SSHD_DIR}/ssh_host_ed25519_key" -N "" -a 100
ssh-keygen -t rsa -b 4096 -f "${SSHD_DIR}/ssh_host_rsa_key" -N ""
chmod 600 "${SSHD_DIR}/ssh_host_ed25519_key" "${SSHD_DIR}/ssh_host_rsa_key"
chmod 644 "${SSHD_DIR}/ssh_host_ed25519_key.pub" "${SSHD_DIR}/ssh_host_rsa_key.pub"

if ! grep -q "^Include ${SSHD_CUSTOM_DIR}/\*.conf" "${SSHD_CFG}"; then
    sed -i "1i Include ${SSHD_CUSTOM_DIR}/*.conf" "${SSHD_CFG}"
fi

tee "${SSHD_CUSTOM_CFG}" >/dev/null <<EOF
# Hardened SSH Configuration - Managed by setup-laptop-mintos.sh (${BACKUP_TS})

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
HostbasedAuthentication no
PermitEmptyPasswords no
KbdInteractiveAuthentication no
GSSAPIAuthentication no
GSSAPIKeyExchange no
UsePAM yes
LoginGraceTime 30
MaxAuthTries 4
MaxSessions 10

HostKey ${SSHD_DIR}/ssh_host_ed25519_key
HostKey ${SSHD_DIR}/ssh_host_rsa_key

AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
UseDNS no
PrintMotd no
PrintLastLog yes
AcceptEnv LANG LC_*

Match Address ${LAN_CIDR},fe80::/10,fc00::/7
  PermitRootLogin yes
  PasswordAuthentication yes
  AllowTcpForwarding no
EOF
chmod 644 "${SSHD_CUSTOM_CFG}"

if ! grep -REq '^[[:space:]]*Subsystem[[:space:]]+sftp' "${SSHD_CFG}" "${SSHD_CUSTOM_DIR}/"; then
    warn "No sftp Subsystem defined - adding to ${SSHD_CFG}."
    echo "Subsystem sftp /usr/lib/openssh/sftp-server" >> "${SSHD_CFG}"
fi

info "Validating sshd_config..."
if sshd -t; then
    info "sshd_config OK, restarting..."
    systemctl restart ssh || systemctl restart sshd || error "ssh restart failed."
else
    error "sshd_config test FAILED - not restarting ssh. Backup: ${BACKUP_DIR}/sshd_config.bak"
fi

# ================================================================================
# SECTION 9: Trusted Certificate (optional)
# ================================================================================

step "Section 9: Trusted Certificate"

if [[ -z "${CERT_URL}" ]]; then
    info "No CERT_URL configured -- skipping certificate install."
else
    CERT_ZIP="/tmp/blockpage-cert.zip"
    CERT_EXTRACT_DIR="/tmp/blockpage-cert-extract"
    CERT_TMP="/tmp/blockpage-cert.cer"
    CERT_DST="/usr/local/share/ca-certificates/blockpage-ca.crt"

    rm -rf "${CERT_EXTRACT_DIR}"
    rm -f "${CERT_ZIP}" "${CERT_TMP}" "${CERT_TMP}.pem"

    if ! command -v unzip >/dev/null 2>&1; then
        info "unzip not present -- installing..."
        if ! apt-get install -y unzip >/dev/null 2>&1; then
            warn "Failed to install unzip -- skipping cert install."
        fi
    fi

    if command -v unzip >/dev/null 2>&1; then
        info "Downloading certificate archive..."
        if ! wget -q -O "${CERT_ZIP}" "${CERT_URL}"; then
            warn "Failed to download certificate from CERT_URL -- check it's reachable."
        else
            info "Extracting certificate from archive..."
            mkdir -p "${CERT_EXTRACT_DIR}"
            if ! unzip -o -q "${CERT_ZIP}" -d "${CERT_EXTRACT_DIR}"; then
                warn "Failed to extract archive -- skipping cert install."
            else
                FOUND_CERT="$(find "${CERT_EXTRACT_DIR}" -type f -iname '*.cer' | head -n1)"
                if [[ -z "${FOUND_CERT}" ]]; then
                    FOUND_CERT="$(find "${CERT_EXTRACT_DIR}" -type f | head -n1)"
                fi

                if [[ -z "${FOUND_CERT}" ]]; then
                    warn "No certificate file found in archive -- skipping cert install."
                else
                    cp "${FOUND_CERT}" "${CERT_TMP}"

                    if ! grep -q "BEGIN CERTIFICATE" "${CERT_TMP}"; then
                        info "Attempting DER-to-PEM conversion..."
                        if openssl x509 -inform DER -in "${CERT_TMP}" -out "${CERT_TMP}.pem" 2>/dev/null; then
                            mv "${CERT_TMP}.pem" "${CERT_TMP}"
                        else
                            warn "File is not valid PEM or DER -- skipping cert install."
                            rm -f "${CERT_TMP}" "${CERT_TMP}.pem"
                        fi
                    fi

                    if [[ -f "${CERT_TMP}" ]] && grep -q "BEGIN CERTIFICATE" "${CERT_TMP}"; then
                        cp "${CERT_TMP}" "${CERT_DST}"
                        update-ca-certificates \
                            && info "Certificate installed to ${CERT_DST}" \
                            || warn "update-ca-certificates failed."
                    fi
                fi
            fi
        fi
    fi

    rm -rf "${CERT_EXTRACT_DIR}"
    rm -f "${CERT_ZIP}" "${CERT_TMP}" "${CERT_TMP}.pem"
fi

# ================================================================================
# SECTION 10: Firmware Updates (fwupd)
# Skips inside VMs/containers; classifies updates by plugin name (locale-proof);
# fwupd governs power/safety itself; UEFI/Secure Boot gated off by default.
# Knobs: FWUPD_APPLY (default 1), FWUPD_APPLY_UEFI (default 0),
# FWUPD_ALLOW_VIRT (default 0). Selective UEFI gating requires jq.
# ================================================================================

step "Section 10: Firmware Updates"

: "${FWUPD_APPLY:=1}"
: "${FWUPD_APPLY_UEFI:=0}"
: "${FWUPD_ALLOW_VIRT:=0}"

FWUPD_REBOOT_REQUIRED=0        # caller reads this after the section

# systemd-detect-virt exits 1 on bare metal even when it prints "none".
_virt="$(systemd-detect-virt 2>/dev/null || true)"; _virt="${_virt:-none}"

if [[ "${_virt}" != "none" && "${FWUPD_ALLOW_VIRT}" -ne 1 ]]; then
    info "Virtualisation detected (${_virt}) -- host firmware not managed here, skipping."
elif ! command -v fwupdmgr &>/dev/null; then
    warn "fwupdmgr not found -- skipping firmware updates."
else
    info "Refreshing LVFS metadata..."
    if ! timeout 60 fwupdmgr refresh --force >/dev/null 2>&1; then
        warn "LVFS metadata refresh failed -- continuing with cached metadata."
    fi

    info "Checking for firmware updates..."
    _rc=0
    _updates="$(timeout 60 fwupdmgr get-updates 2>&1)" || _rc=$?

    case "${_rc}" in
        2)   info "Firmware is up to date." ;;
        3)   info "No updatable devices supported on this hardware." ;;
        124) warn "get-updates timed out -- skipping this run." ;;
        0)
            info "Firmware updates available:"
            printf '%s\n' "${_updates}"

            _json="$(timeout 30 fwupdmgr get-updates --json 2>/dev/null || true)"
            _has_capsule=0; _has_sbkeys=0
            if command -v jq &>/dev/null && [[ -n "${_json}" ]]; then
                if jq -e '.Devices[]? | select((.Plugin // "")|test("uefi_capsule";"i"))' \
                     <<<"${_json}" >/dev/null 2>&1; then _has_capsule=1; fi
                if jq -e '.Devices[]? | select((.Plugin // "")|test("uefi_(dbx|db|kek|pk)";"i"))' \
                     <<<"${_json}" >/dev/null 2>&1; then _has_sbkeys=1; fi
            elif [[ -n "${_json}" ]]; then
                if grep -Eqi '"uefi_capsule"'        <<<"${_json}"; then _has_capsule=1; fi
                if grep -Eqi '"uefi_(dbx|db|kek|pk)"' <<<"${_json}"; then _has_sbkeys=1; fi
            fi
            _has_uefi=0
            if [[ "${_has_capsule}" -eq 1 || "${_has_sbkeys}" -eq 1 ]]; then _has_uefi=1; fi

            if [[ "${FWUPD_APPLY}" -ne 1 ]]; then
                info "Report-only (FWUPD_APPLY=0). Apply manually: sudo fwupdmgr update"
            elif [[ "${_has_uefi}" -eq 0 ]]; then
                info "Applying firmware updates (fwupd governs power/safety)..."
                if timeout 600 fwupdmgr update -y --no-reboot-check 2>&1; then
                    ok "Firmware update pass completed."
                else
                    warn "One or more firmware updates failed or were deferred."
                fi
                if printf '%s' "${_updates}" | grep -qi 'needs a reboot'; then
                    FWUPD_REBOOT_REQUIRED=1
                fi
            elif [[ "${FWUPD_APPLY_UEFI}" -eq 1 ]]; then
                info "Applying all updates incl. UEFI/Secure Boot (staged for reboot)..."
                if timeout 600 fwupdmgr update -y --no-reboot-check 2>&1; then
                    ok "Update pass completed."
                else
                    warn "One or more updates failed or were deferred."
                fi
                FWUPD_REBOOT_REQUIRED=1
                if [[ "${_has_capsule}" -eq 1 ]]; then
                    warn "UEFI capsule staged -- REBOOT to apply. Do not interrupt power."
                fi
                if [[ "${_has_sbkeys}" -eq 1 ]]; then
                    warn "Secure Boot key/dbx update staged -- REBOOT to commit."
                fi
            elif command -v jq &>/dev/null && [[ -n "${_json}" ]]; then
                info "Applying non-UEFI updates only (UEFI gated; set FWUPD_APPLY_UEFI=1)..."
                mapfile -t _ids < <(jq -r \
                    '.Devices[]? | select(((.Plugin // "")|test("uefi";"i"))|not) | .DeviceId // empty' \
                    <<<"${_json}" 2>/dev/null || true)
                if [[ "${#_ids[@]}" -eq 0 ]]; then
                    info "No non-UEFI updates to apply."
                else
                    for _id in "${_ids[@]}"; do
                        info "Updating device ${_id}..."
                        if ! timeout 600 fwupdmgr update "${_id}" -y --no-reboot-check 2>&1; then
                            warn "Update failed or deferred for ${_id}."
                        fi
                    done
                    if jq -e '.Devices[]? | select(((.Plugin // "")|test("uefi";"i"))|not)
                              | select((.Flags // []) | index("needs-reboot"))' \
                         <<<"${_json}" >/dev/null 2>&1; then
                        FWUPD_REBOOT_REQUIRED=1
                    fi
                fi
                warn "UEFI/Secure Boot updates held -- set FWUPD_APPLY_UEFI=1 to apply."
            else
                warn "UEFI updates present but jq unavailable to gate them safely."
                warn "Install jq, or set FWUPD_APPLY_UEFI=1 to apply everything."
            fi
            ;;
        *)   warn "get-updates returned unexpected code ${_rc} -- skipping." ;;
    esac

    if [[ "${FWUPD_REBOOT_REQUIRED}" -eq 1 ]]; then
        warn "REBOOT REQUIRED to finish applying staged firmware. Do not interrupt power."
    else
        info "No firmware reboot pending."
    fi
fi

# ================================================================================
# SECTION 11: Automatic Security Updates
# ================================================================================

step "Section 11: Automatic Security Updates"

info "Configuring unattended-upgrades..."
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF

systemctl enable --now unattended-upgrades \
    || warn "unattended-upgrades service failed to enable."
info "Automatic security updates configured. Full upgrades remain manual: sudo apt upgrade"

# ================================================================================
# USER-SPECIFIC SOFTWARE (only for TARGET_USER, if set)
# ================================================================================

step "User-Specific Software"

if [[ -z "${TARGET_USER}" ]]; then
    info "TARGET_USER not set -- skipping user-specific installs."
elif [[ "${CURRENT_USER}" == "${TARGET_USER}" ]]; then
    info "User is ${TARGET_USER} -- installing user-specific packages..."

    flatpak install -y --noninteractive flathub com.google.AndroidStudio \
        || warn "AndroidStudio Flatpak install failed."

    flatpak install -y --noninteractive flathub com.github.marhkb.Pods \
        || warn "Pods Flatpak install failed."

    flatpak install -y --noninteractive flathub com.visualstudio.code \
        || warn "VS Code Flatpak install failed."

    apt install -y git \
        || warn "git installation failed."
    info "User-specific software installation complete: Android Studio, Pods, VS Code (Flatpak), git (apt)"
else
    info "User is ${CURRENT_USER}, not ${TARGET_USER} -- skipping user-specific installs."
fi

# ================================================================================
# MOTD
# ================================================================================

step "MOTD Configuration"

rm -f /etc/update-motd.d/*

cat > /etc/update-motd.d/80-sysinfo << 'EOF'
#!/bin/bash
echo "=== System Information ==="
echo ""
echo "  🖥️  Operating System : $(lsb_release -ds 2>/dev/null || grep -oP '(?<=PRETTY_NAME=\").*(?=\")' /etc/os-release)"
echo "  🌐  IPv4 Address     : $(hostname -I 2>/dev/null | awk '{print $1}')"
echo "  🔗  IPv6 Global      : $(ip -6 addr show scope global 2>/dev/null | grep inet6 | awk '{print $2}' | cut -d'/' -f1 | head -n1)"
echo "  🪢  IPv6 Link-local  : $(ip -6 addr show scope link  2>/dev/null | grep inet6 | awk '{print $2}' | cut -d'/' -f1 | head -n1)"
echo "  ⏱️  System Uptime    : $(uptime -p 2>/dev/null)"
echo ""
EOF
chmod +x /etc/update-motd.d/80-sysinfo

# ================================================================================
# DIAGNOSTICS
# ================================================================================

step "Diagnostics"

echo ""
echo "============================================================"
echo "  DIAGNOSTIC CHECK"
echo "============================================================"

echo ""
echo "  -- Network ------------------------------------------"
DEFAULT_IF=$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}')
DEFAULT_GW=$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')
if [[ -n "${DEFAULT_IF}" ]]; then
    info "Default interface : ${DEFAULT_IF}"
    info "Default gateway   : ${DEFAULT_GW}"
else
    warn "No IPv4 default route found."
fi

IPV6_GW=$(ip -6 route show default 2>/dev/null | awk '{print $3; exit}')
if [[ -n "${IPV6_GW}" ]]; then
    info "IPv6 default gateway: ${IPV6_GW}"
else
    info "No IPv6 default route (not required)."
fi

echo ""
echo "  -- DNS ----------------------------------------------"
if command -v resolvectl &>/dev/null; then
    resolvectl status 2>/dev/null | grep -E "DNS Servers|Current DNS" | head -5 || true
fi

if command -v dig &>/dev/null; then
    DNS_TEST=$(dig +short +timeout=5 "${DNS_TEST_HOST}" 2>/dev/null | head -1) || true
    if [[ -n "${DNS_TEST}" ]]; then
        info "DNS resolution OK (${DNS_TEST_HOST} -> ${DNS_TEST})"
    else
        warn "DNS resolution failed for ${DNS_TEST_HOST} -- check /etc/resolv.conf"
    fi
else
    warn "dig not available -- skipping DNS resolution check."
fi

echo ""
echo "  -- UFW ----------------------------------------------"
if ufw status | grep -q "Status: active"; then
    info "UFW active"
    ufw status | grep -E "631|443|9100|5353|22|51820" || true
else
    warn "UFW not active"
fi

echo ""
echo "  -- Failed Systemd Units -----------------------------"
FAILED_UNITS=$(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}')
if [[ -z "${FAILED_UNITS}" ]]; then
    info "No failed systemd units."
else
    while IFS= read -r unit; do
        warn "Failed unit: ${unit}"
    done <<< "${FAILED_UNITS}"
fi

echo ""
echo "============================================================"

# ================================================================================
# POST-SCRIPT INSTRUCTIONS
# ================================================================================

cat << 'POSTINSTALL'

============================================================
  POST-SCRIPT ACTIONS
============================================================

1. REBOOT RECOMMENDED
   AppArmor changes take full effect after a reboot.
   Run:  sudo reboot

2. INSYNC SETUP (after reboot)
   Open Insync from the application launcher.
   Sign in with your Google/OneDrive account(s) and configure sync.

3. WIREGUARD VPN
   Place your .conf file in /etc/wireguard/ then:
     sudo wg-quick up /etc/wireguard/[your-config].conf

4. CLAMAV
   Scan home:  clamscan --recursive --infected ~/
   Update:     sudo freshclam

5. MANUAL SYSTEM UPDATE
   sudo apt update && sudo apt upgrade

============================================================

POSTINSTALL

info "Setup complete. Version 3.7.1"
info "Log: ${LOGFILE}"
info "SSH backup: ${BACKUP_DIR}"
echo ""

#endscript

The script contains specific code in relation to the following

  1. The UDM certificate - left in for clarity
  2. Postscript setups - missing from the script - there are a few like, postscript, fail2ban, clamav and others.

Notes:

  1. Flatpak is a seriously good system - use it.
  2. I am a human being and as such I am flawed. Test it, modify it, deploy it.

#enoughsaid