MintOS - Post Script - Updated
Updated: 28-07-2026
The following code is shared.
The script was then reengineered back to MintOS with a few changes
- MintOS is my daily driver
- MintOS has advantages for me that BazziteOS doesnt accommodate easily
- This code has numerous changes to accommodate my needs
- 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
- Based on learning.
- 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.
- Script has evolved from Debian 13.0 to Debian 13.6.
- Cut down on the endless comments - simplify.
- Set more variables - simplify - more generic.
- 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 ""
#endscriptThe script contains specific code in relation to the following
- The UDM certificate - left in for clarity
- Postscript setups - missing from the script - there are a few like, postscript, fail2ban, clamav and others.
Notes:
- Flatpak is a seriously
goodsystem - use it. - I am a human being and as such I am flawed. Test it, modify it, deploy it.
#enoughsaid