Table of Contents

  1. 1. Changed root password to something random
  2. 2. Wiped all configurations and connections in NetworkManager
  3. 3. Corrupted Bootloader entry
  4. 4. Disabled sshd daemon
  5. 5. Block ssh in firewall
  6. 6. Masked firewalld systemd unit
  7. 7. Created systemd unit to hog up CPU usage
  8. 8. Changed default UMASK in /etc/login.defs to 000
  9. 9. Symlinked /home to /dev/null
  10. 10. Masked network.target
  11. 11. Change default port for httpd to random port
  12. 13. Changed attributes on login.defs , hosts , and sudoers file to make it immutable
  13. 14. Used script to change selinux type of 20 random files in /etc and /var
  14. RHCSA Break Script

In order to get more practice studying for RHCSA , i’ve decided to cobble together a few tactics that will force me to stop , think , and fix various things on my VM. I’ve done this as it’s easy to coast along doing the same repetitive tasks while studying hammer commands & snippets. This however is a chance to break outside of the repition and force me to apply the knowledge and fix a very broken VM. This is not for the faint of heart, you have been warned.

1. Changed root password to something random

Setup root password as something random , nuff said

2. Wiped all configurations and connections in NetworkManager

sudo rm -rf /etc/NetworkManager/system-connections/*

3. Corrupted Bootloader entry

  1. Changed default systemd unit to boot into getty.target

4. Disabled sshd daemon

systemctl disable sshd

5. Block ssh in firewall

firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=0.0.0.0/0 service name=ssh drop'
firewall-cmd --reload

6. Masked firewalld systemd unit

systemctl mask firewalld.service

7. Created systemd unit to hog up CPU usage

[Unit]
Description=Hog up CPU usage 
StartLimitIntervalSec=0

[Service]
ExecStart=/usr/bin/yes
StandardOutput=null
StandardError=null
Restart=always
RestartSec=0

# Remove caps on resource usage 
CPUQuota=0 
MemoryMax=infinity
MemorySwapMax=infinity
TasksMax=infinity

[Install]
WantedBy=multi-user.target

8. Changed default UMASK in /etc/login.defs to 000

UMASK 000 

9. Symlinked /home to /dev/null

sudo ln -s /dev/null /home

10. Masked network.target

systemctl mask network.target

11. Change default port for httpd to random port

13. Changed attributes on login.defs , hosts , and sudoers file to make it immutable

chattr +i /etc/login.defs
chattr +i /etc/hosts
chattr +i /etc/sudoers

14. Used script to change selinux type of 20 random files in /etc and /var

Script used

#!/usr/bin/env bash
# selinux-chaos.sh
# Randomly change SELinux *type* on random files, with logging & restore.

set -uo pipefail

LOG_DIR="/var/tmp/selinux-chaos"
LOG_FILE="$LOG_DIR/changes-$(date +%Y%m%d-%H%M%S).csv"
DEFAULT_DIRS=("/var/www" "/srv" "/var/log" "/home")
DEFAULT_COUNT=10
DRY_RUN=0

# Curated list of *object* types that won't instantly brick a box.
# Edit to taste for your labs.
SELINUX_TYPES=(
  httpd_sys_content_t
  httpd_sys_rw_content_t
  httpd_log_t
  httpd_var_run_t
  samba_share_t
  var_log_t
  var_t
  user_home_t
  default_t
  mysqld_db_t
  named_cache_t
)

usage() {
  cat <<'EOF'
Usage:
  selinux-chaos.sh break [-n COUNT] [-d DIR ...] [--dry-run]
  selinux-chaos.sh restore --from LOG.csv
  selinux-chaos.sh restore-defaults [-d DIR ...]

Options:
  -n COUNT       Number of files to alter (default: 10)
  -d DIR ...     One or more directories to randomize (default: /var/www /srv /var/log /home)
  --dry-run      Show what would change without applying it
  --from FILE    CSV produced by this script (path,orig_type,new_type,timestamp)

Examples:
  # Break 20 random files under defaults:
  sudo ./selinux-chaos.sh break -n 20

  # Break 5 files only under /var/www and /home:
  sudo ./selinux-chaos.sh break -n 5 -d /var/www /home

  # Restore using a specific log:
  sudo ./selinux-chaos.sh restore --from /var/tmp/selinux-chaos/changes-YYYYMMDD-HHMMSS.csv

  # Nuke custom types back to defaults in target dirs:
  sudo ./selinux-chaos.sh restore-defaults -d /var/www /srv
EOF
}

need_root() {
  if [[ $(id -u) -ne 0 ]]; then
    echo "This script must run as root." >&2
    exit 1
  fi
}

check_selinux_enforcing() {
  if ! command -v getenforce >/dev/null 2>&1; then
    echo "getenforce not found. Is SELinux installed?" >&2
    exit 1
  fi
  mode=$(getenforce)
  if [[ "$mode" != "Enforcing" && "$mode" != "Permissive" ]]; then
    echo "SELinux appears disabled (getenforce=$mode). Enable it for a useful lab." >&2
  fi
}

ensure_tools() {
  for bin in ls chcon restorecon find shuf awk grep sed; do
    command -v "$bin" >/dev/null 2>&1 || { echo "Missing dependency: $bin" >&2; exit 1; }
  done
}

is_safe_path() {
  # Skip dangerous/system locations no matter what.
  case "$1" in
    /proc/*|/sys/*|/dev/*|/run/*|/boot/*|/etc/selinux/*|/usr/lib/selinux/*|/var/lib/selinux/*) return 1 ;;
    *) return 0 ;;
  esac
}

get_type() {
  # Extract the SELinux *type* (3rd field) from ls -Zd
  local f="$1"
  local ctx
  # If file disappears mid-run, return empty
  ctx=$(ls -Zd -- "$f" 2>/dev/null | awk '{print $1}' || true)
  [[ -z "$ctx" ]] && { echo ""; return 0; }
  echo "$ctx" | awk -F: '{print $3}'
}

pick_random_type() {
  local current="$1"
  # Shuffle types until we pick one different from current
  printf "%s\n" "${SELINUX_TYPES[@]}" | shuf | awk -v cur="$current" '$0 != cur {print; exit}'
}

cmd_break() {
  local count="$DEFAULT_COUNT"
  local -a dirs=("${DEFAULT_DIRS[@]}")

  # Parse args
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -n) count="$2"; shift 2 ;;
      -d) shift; dirs=(); while [[ $# -gt 0 && "$1" != -* ]]; do dirs+=("$1"); shift; done ;;
      --dry-run) DRY_RUN=1; shift ;;
      *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
  done

  [[ "$count" =~ ^[0-9]+$ ]] || { echo "-n COUNT must be a number" >&2; exit 1; }

  # Build candidate list
  mapfile -t candidates < <(
    for d in "${dirs[@]}"; do
      [[ -d "$d" ]] || continue
      is_safe_path "$d" || continue
      # Regular files only; skip sockets/fifos/symlinks
      find "$d" -xdev -type f -readable 2>/dev/null
    done | grep -Ev '^$' | shuf
  )

  if [[ ${#candidates[@]} -eq 0 ]]; then
    echo "No candidate files found in: ${dirs[*]}" >&2
    exit 1
  fi

  mkdir -p "$LOG_DIR"
  echo "#path,orig_type,new_type,timestamp" > "$LOG_FILE"

  local changed=0
  for f in "${candidates[@]}"; do
    is_safe_path "$f" || continue
    orig_type=$(get_type "$f")
    [[ -z "$orig_type" ]] && continue
    new_type=$(pick_random_type "$orig_type")
    [[ -z "$new_type" ]] && continue

    echo "[$((changed+1))/$count] $f  $orig_type -> $new_type"
    echo "$f,$orig_type,$new_type,$(date -Is)" >> "$LOG_FILE"

    if [[ "$DRY_RUN" -eq 0 ]]; then
      if ! chcon -t "$new_type" -- "$f" 2>/dev/null; then
        echo "  ! chcon failed on $f (skipping)" >&2
        # Remove the last log line since it didn’t apply
        sed -i '$d' "$LOG_FILE"
        continue
      fi
    fi

    ((changed++))
    [[ "$changed" -ge "$count" ]] && break
  done

  if [[ "$DRY_RUN" -eq 1 ]]; then
    echo "Dry run complete. No changes applied."
    # Don’t keep a dry-run log
    rm -f "$LOG_FILE"
  else
    echo "Changes logged to: $LOG_FILE"
    echo "Tip: grep your AVCs with 'ausearch -m avc -ts recent' then practice restorecon or booleans."
  fi
}

cmd_restore_from_log() {
  local file=""
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --from) file="$2"; shift 2 ;;
      *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
  done
  [[ -f "$file" ]] || { echo "Log file not found: $file" >&2; exit 1; }

  echo "Restoring SELinux types from $file ..."
  # Skip comment/header lines
  while IFS=, read -r path orig new ts; do
    [[ "$path" =~ ^# ]] && continue
    [[ -z "$path" || -z "$orig" ]] && continue
    if [[ -e "$path" ]]; then
      echo "chcon -t $orig -- $path"
      chcon -t "$orig" -- "$path" 2>/dev/null || echo "  ! Failed on $path"
    fi
  done < "$file"
  echo "Done."
}

cmd_restore_defaults() {
  local -a dirs=("${DEFAULT_DIRS[@]}")
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -d) shift; dirs=(); while [[ $# -gt 0 && "$1" != -* ]]; do dirs+=("$1"); shift; done ;;
      *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
  done

  for d in "${dirs[@]}"; do
    [[ -d "$d" ]] || continue
    is_safe_path "$d" || continue
    echo "restorecon -RFv $d"
    restorecon -RFv "$d"
  done
  echo "Defaults restored for target dirs."
}

main() {
  [[ $# -lt 1 ]] && { usage; exit 1; }
  sub="$1"; shift || true

  case "$sub" in
    break)
      need_root
      check_selinux_enforcing
      ensure_tools
      cmd_break "$@"
      ;;
    restore)
      need_root
      ensure_tools
      cmd_restore_from_log "$@"
      ;;
    restore-defaults)
      need_root
      ensure_tools
      cmd_restore_defaults "$@"
      ;;
    -h|--help|help)
      usage
      ;;
    *)
      echo "Unknown subcommand: $sub" >&2
      usage
      exit 1
      ;;
  esac
}

main "$@"

Changes are logged in /tmp


RHCSA Break Script

I have also generated a script using chatgpt that will break various parts of the system , this script can be fine tuned to either break alot or very little. There is also an option to reverse the damage. See the script below.


#!/usr/bin/env bash
# rhcsa-breaker.sh — Intentionally "break" a RHEL 9 VM for RHCSA practice
# Levels: light | medium | heavy
# Always creates backups under /root/rhcsa_breaker_backup/<timestamp>
# Supports: --level <lvl>, --dry-run, --list, --restore <backup_dir>
# Tested on: RHEL/Alma/Rocky 9
set -euo pipefail

LEVEL="${1:-}"
shift || true ||:

DRY_RUN=false
RESTORE_DIR=""
SHOW_LIST=false

# ---------- helpers ----------
log(){ echo "[*] $*"; }
warn(){ echo "[!] $*" >&2; }
die(){ echo "[x] $*" >&2; exit 1; }

require_root(){
  [[ $EUID -eq 0 ]] || die "Run as root."
}

ts(){ date +%Y%m%d-%H%M%S; }

backup_root="/root/rhcsa_breaker_backup"
timestamp="$(ts)"
workdir="$backup_root/$timestamp"
manifest="$workdir/manifest.txt"

ensure_dirs(){
  mkdir -p "$workdir"
  touch "$manifest"
}

backup_file(){
  local f="$1"
  if [[ -e "$f" ]]; then
    local dest="$workdir$(echo "$f" | sed 's#/#_#g')"
    $DRY_RUN || cp -a "$f" "$dest"
    echo "FILE $f -> $dest" >> "$manifest"
  fi
}

backup_cmd_out(){
  local name="$1"; shift
  local dest="$workdir/cmd_${name}.txt"
  $DRY_RUN || ( "$@" > "$dest" 2>&1 || true )
  echo "CMD $name -> $dest : $*" >> "$manifest"
}

record_change(){
  # id | description | hint
  echo "CHANGE|$1|$2|$3" >> "$manifest"
}

apply(){
  if $DRY_RUN; then
    echo "DRYRUN: $*"
  else
    eval "$@"
  fi
}

usage(){
cat <<EOF
Usage:
  $0 --level {light|medium|heavy} [--dry-run]
  $0 --list
  $0 --restore <backup_dir>

Examples:
  $0 --level light
  $0 --level medium --dry-run
  $0 --restore /root/rhcsa_breaker_backup/20250824-120102

Notes:
- Backups & a manifest are stored in $backup_root/<timestamp>.
- Use the manifest for fix hints and to see what changed.
EOF
}

# ---------- parse args ----------
while [[ $# -gt 0 ]]; do
  case "$1" in
    --level) LEVEL="$2"; shift 2;;
    --dry-run) DRY_RUN=true; shift;;
    --restore) RESTORE_DIR="$2"; shift 2;;
    --list) SHOW_LIST=true; shift;;
    -h|--help) usage; exit 0;;
    *) shift;;
  esac
done

list_breaks(){
  cat <<'LIST'
LIGHT (aimed at 15–30 min of fixes):
  - firewalld: drop SSH/Cockpit services in active zone
  - SELinux: relabel wrong context on /var/www/html/index.html
  - SELinux: toggle a boolean (httpd_can_network_connect) to off
  - systemd: make a service fail (create bogus override for rsyslog)
  - Users/Groups: lock a user and break sudo via removed wheel membership of a secondary test user
  - Permissions: remove execute bit on a common tool (/usr/bin/less via per-file ACL)

MEDIUM (45–90 min):
  - fstab: add an entry that fails to mount (nofail) + wrong options
  - Networking: create a bad NM connection profile and set it autoconnect-priority higher
  - systemd: mask a service (chronyd) and stop it
  - firewalld: move interface to the wrong zone & prune services
  - SELinux: mislabel a custom port (bind 8081 as http_port_t then change to ssh_port_t)
  - Permissions/ACL: give odd ACL on /srv/share and remove default perms from a file

HEAVY (multi-hour set; snapshot recommended):
  - systemd: mask multi-user dependencies (disable crond, atd; add bogus drop-in to NetworkManager)
  - PAM: require a module that isn't installed for login shells (but leave console TTY usable)
  - SSH: change port, disable password auth, and restrict to a non-existent AllowUsers
  - firewalld: default zone to drop, remove services from public
  - DNS: point resolvers to invalid servers
  - SELinux: change file context recursively on /var/www/html to a wrong type
  - Users: expire a user's password & lock another
LIST
}

# ---------- individual breakers ----------
break_firewalld_light(){
  log "Adjusting firewalld (drop SSH/Cockpit in active zone)"
  backup_cmd_out "firewalld_list" firewall-cmd --list-all
  local zone
  zone="$(firewall-cmd --get-active-zones | awk 'NR==1{print $1}')"
  [[ -z "$zone" ]] && zone="public"
  apply "firewall-cmd --permanent --zone $zone --remove-service=ssh || true"
  apply "firewall-cmd --permanent --zone $zone --remove-service=cockpit || true"
  apply "firewall-cmd --reload"
  record_change "FIREWALL1" "Removed ssh/cockpit from zone '$zone'." "firewall-cmd --zone $zone --add-service=ssh --permanent; firewall-cmd --reload"
}

break_selinux_file_context_light(){
  log "Mislabel a single web file"
  mkdir -p /var/www/html
  [[ -e /var/www/html/index.html ]] || echo "hello" > /var/www/html/index.html
  backup_cmd_out "semanage_fcontext_list" semanage fcontext -l
  apply "semanage fcontext -a -t net_conf_t '/var/www/html/index.html'"
  apply "restorecon -v /var/www/html/index.html"  # will set wrong due to rule
  record_change "SELINUX1" "Set fcontext of index.html to net_conf_t." "semanage fcontext -d '/var/www/html/index.html'; restorecon -v /var/www/html/index.html"
}

break_selinux_boolean_light(){
  log "Toggle SELinux boolean httpd_can_network_connect OFF"
  backup_cmd_out "getsebool_httpd_net" getsebool httpd_can_network_connect
  apply "setsebool -P httpd_can_network_connect off"
  record_change "SELINUX2" "httpd_can_network_connect=off." "setsebool -P httpd_can_network_connect on"
}

break_systemd_override_light(){
  log "Break rsyslog via bogus override"
  mkdir -p /etc/systemd/system/rsyslog.service.d
  backup_file /etc/systemd/system/rsyslog.service.d/override.conf
  cat > /tmp/override.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/bin/false
EOF
  apply "cp /tmp/override.conf /etc/systemd/system/rsyslog.service.d/override.conf"
  apply "systemctl daemon-reload"
  apply "systemctl restart rsyslog || true"
  record_change "SYSTEMD1" "rsyslog override forces failure." "rm -f /etc/systemd/system/rsyslog.service.d/override.conf; systemctl daemon-reload; systemctl restart rsyslog"
}

break_user_group_light(){
  log "Create test user 'labuser', remove wheel, lock account"
  id labuser &>/dev/null || apply "useradd -m labuser"
  backup_cmd_out "groups_labuser" id labuser
  apply "gpasswd -d labuser wheel || true"
  apply "passwd -l labuser"
  record_change "USER1" "labuser removed from wheel and locked." "usermod -aG wheel labuser; passwd -u labuser"
}

break_acl_light(){
  log "Remove execute on /usr/bin/less via ACL (does not touch base perms)"
  backup_cmd_out "getfacl_less" getfacl /usr/bin/less
  apply "setfacl -m u::r-- /usr/bin/less || true"
  record_change "ACL1" "less made non-executable via ACL." "setfacl -b /usr/bin/less"
}

break_fstab_medium(){
  log "Add failing fstab entry with nofail (practice mount troubleshooting)"
  backup_file /etc/fstab
  echo "# RHCSA-BREAKER test" >> /etc/fstab
  echo "UUID=0000-FAKE-UUID  /mnt/bad  xfs  defaults,nofail  0 0" | tee -a /etc/fstab >/dev/null
  apply "mkdir -p /mnt/bad"
  record_change "FSTAB1" "Added bad fstab entry." "edit /etc/fstab to remove/fix the bad line; then systemctl daemon-reload; mount -a"
}

break_nm_medium(){
  log "Create a bad NetworkManager profile with higher priority"
  backup_cmd_out "nmcli_con_show" nmcli -t -f NAME,UUID,DEVICE con show
  apply "nmcli con add type ethernet ifname eth0 con-name bad-eth0 || true"
  apply "nmcli con mod bad-eth0 ipv4.method manual ipv4.addresses 192.0.2.10/24 ipv4.gateway 192.0.2.1 ipv4.dns '203.0.113.53' autoconnect yes connection.autoconnect-priority 10 || true"
  record_change "NET1" "Added bad-eth0 NM profile with autoconnect priority." "nmcli con delete bad-eth0 OR fix the IP/gw/dns; check 'nmcli con up <good>' and priorities"
}

break_systemd_mask_medium(){
  log "Mask chronyd"
  backup_cmd_out "systemctl_status_chronyd" systemctl status chronyd || true
  apply "systemctl stop chronyd || true"
  apply "systemctl mask chronyd"
  record_change "SYSTEMD2" "chronyd masked." "systemctl unmask chronyd; systemctl enable --now chronyd"
}

break_firewalld_zone_medium(){
  log "Move interface to 'work' zone and prune services"
  local ifc; ifc="$(nmcli -t -f DEVICE connection show --active | head -n1)"
  [[ -z "$ifc" ]] && ifc="$(nmcli -t -f DEVICE dev status | awk -F: 'NR==2{print $1}')"
  [[ -z "$ifc" ]] && ifc="eth0"
  apply "firewall-cmd --permanent --zone=work --add-interface=$ifc"
  apply "firewall-cmd --permanent --zone=public --remove-interface=$ifc || true"
  apply "firewall-cmd --permanent --zone=work --remove-service=ssh || true"
  apply "firewall-cmd --reload"
  record_change "FIREWALL2" "Interface $ifc moved to 'work' and ssh removed." "firewall-cmd --permanent --zone=public --add-interface=$ifc; firewall-cmd --permanent --zone=public --add-service=ssh; firewall-cmd --reload"
}

break_selinux_port_medium(){
  log "Mislabel a port: set 8081 as ssh_port_t"
  backup_cmd_out "semanage_port_list" semanage port -l
  apply "semanage port -a -t ssh_port_t -p tcp 8081 || semanage port -m -t ssh_port_t -p tcp 8081"
  record_change "SELINUX3" "8081 labeled as ssh_port_t." "semanage port -d -t ssh_port_t -p tcp 8081 OR set to correct type; semanage port -l | grep 8081"
}

break_acl_medium(){
  log "Odd ACL on /srv/share"
  apply "mkdir -p /srv/share"
  backup_cmd_out "getfacl_srv_share" getfacl -p /srv/share
  apply "setfacl -m u:labuser:--- /srv/share"
  record_change "ACL2" "Deny labuser on /srv/share via ACL." "setfacl -b /srv/share OR setfacl -x u:labuser /srv/share"
}

# HEAVY breakers
break_systemd_heavy(){
  log "Systemd heavy: disable timers/services and add bad drop-in"
  backup_cmd_out "systemctl_list" systemctl list-unit-files --type=service --no-pager
  apply "systemctl disable --now crond || true"
  apply "systemctl disable --now atd || true"
  mkdir -p /etc/systemd/system/NetworkManager.service.d
  backup_file /etc/systemd/system/NetworkManager.service.d/zz-bad.conf
  cat > /tmp/zz-bad.conf <<'EOF'
[Service]
ExecStartPost=/bin/false
EOF
  apply "cp /tmp/zz-bad.conf /etc/systemd/system/NetworkManager.service.d/zz-bad.conf"
  apply "systemctl daemon-reload"
  apply "systemctl restart NetworkManager || true"
  record_change "SYSTEMD3" "Disabled crond/atd; bad NM drop-in." "enable/start crond & atd; remove drop-in; daemon-reload; restart NetworkManager"
}

break_pam_heavy(){
  log "PAM heavy: add non-existent module to system-auth (TTY still usable)"
  backup_file /etc/pam.d/system-auth
  apply "sed -i '1iauth    required    pam_doesnotexist.so' /etc/pam.d/system-auth"
  record_change "PAM1" "Added bad module to /etc/pam.d/system-auth." "edit /etc/pam.d/system-auth to remove the line; run authconfig/realmd checks; test with 'su -'"
}

break_sshd_heavy(){
  log "SSHD heavy: change port, disable PasswordAuth, bad AllowUsers"
  backup_file /etc/ssh/sshd_config
  apply "sed -i 's/^#\?Port .*/Port 22222/' /etc/ssh/sshd_config || echo 'Port 22222' >> /etc/ssh/sshd_config"
  apply "sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config || echo 'PasswordAuthentication no' >> /etc/ssh/sshd_config"
  apply "echo 'AllowUsers nobody' >> /etc/ssh/sshd_config"
  apply "systemctl restart sshd || true"
  record_change "SSH1" "Changed SSH port to 22222; disabled password auth; AllowUsers=nobody." "fix /etc/ssh/sshd_config (Port 22, remove AllowUsers or set correctly, enable PasswordAuthentication if needed); restart sshd; adjust firewalld"
}

break_firewalld_heavy(){
  log "Firewalld heavy: default zone drop, remove services"
  backup_cmd_out "firewalld_zones" firewall-cmd --get-zones
  apply "firewall-cmd --set-default-zone=drop"
  apply "firewall-cmd --permanent --zone=public --remove-service=ssh || true"
  apply "firewall-cmd --reload"
  record_change "FIREWALL3" "Default zone=drop; ssh removed from public." "firewall-cmd --set-default-zone=public; firewall-cmd --permanent --zone=public --add-service=ssh; firewall-cmd --reload"
}

break_dns_heavy(){
  log "DNS heavy: point resolv.conf to invalid resolvers (NetworkManager-managed)"
  backup_file /etc/NetworkManager/conf.d/99-bad-dns.conf
  mkdir -p /etc/NetworkManager/conf.d
  cat > /tmp/99-bad-dns.conf <<'EOF'
[main]
dns=none
[global-dns-domain-*]
servers=203.0.113.253,198.51.100.253
EOF
  apply "cp /tmp/99-bad-dns.conf /etc/NetworkManager/conf.d/99-bad-dns.conf"
  apply "systemctl reload NetworkManager || systemctl restart NetworkManager || true"
  record_change "DNS1" "Invalid DNS via NM conf." "remove /etc/NetworkManager/conf.d/99-bad-dns.conf; reload/restart NetworkManager"
}

break_selinux_heavy(){
  log "SELinux heavy: mislabel entire web root"
  backup_cmd_out "ls_lZ_www" ls -lZ /var/www/html || true
  mkdir -p /var/www/html
  echo "site" > /var/www/html/index.html
  apply "semanage fcontext -a -t var_log_t '/var/www/html(/.*)?'"
  apply "restorecon -Rv /var/www/html"
  record_change "SELINUX4" "Mislabel /var/www/html as var_log_t." "semanage fcontext -d '/var/www/html(/.*)?'; restorecon -Rv /var/www/html"
}

break_users_heavy(){
  log "Users heavy: expire one, lock another"
  id alice &>/dev/null || apply "useradd -m alice"
  id bob &>/dev/null || apply "useradd -m bob"
  apply "chage -E 0 alice"
  apply "passwd -l bob"
  record_change "USER2" "alice expired, bob locked." "chage -E -1 alice; passwd -u bob"
}

# ---------- restore ----------
restore_from(){
  local dir="$1"
  [[ -d "$dir" ]] || die "Backup dir not found: $dir"
  warn "Restoring files from $dir and attempting to revert changes…"
  # Restore files recorded as FILE in manifest
  local man="$dir/manifest.txt"
  [[ -f "$man" ]] || die "Manifest not found in backup dir."
  while IFS= read -r line; do
    if [[ "$line" == FILE* ]]; then
      local src dest
      src="$(echo "$line" | awk '{print $2}')"
      dest="$dir$(echo "$src" | sed 's#/#_#g')"
      if [[ -e "$dest" ]]; then
        log "Restore $src"
        cp -a "$dest" "$src"
      fi
    fi
  done < "$man"
  # Attempt generic reverts
  systemctl daemon-reload || true
  firewall-cmd --reload || true
  restorecon -Rv /var/www/html || true
  log "Done. You may still need to manually undo some changes (see hints in manifest)."
}

# ---------- main ----------
require_root

if $SHOW_LIST; then
  list_breaks
  exit 0
fi

if [[ -n "$RESTORE_DIR" ]]; then
  restore_from "$RESTORE_DIR"
  exit 0
fi

[[ -n "$LEVEL" ]] || { usage; exit 1; }
case "$LEVEL" in
  light|medium|heavy) ;;
  *) die "Unknown level: $LEVEL" ;;
esac

ensure_dirs

log "Backups & manifest: $workdir"
$DRY_RUN && warn "DRY-RUN mode: showing actions only."

# Always capture some baseline info
backup_cmd_out "rpm_list_core" rpm -qa | sort
backup_cmd_out "nmcli_summary" nmcli -t -f DEVICE,STATE,CONNECTION dev status
backup_cmd_out "systemctl_failed" systemctl --failed || true

if [[ "$LEVEL" == "light" ]]; then
  break_firewalld_light
  break_selinux_file_context_light
  break_selinux_boolean_light
  break_systemd_override_light
  break_user_group_light
  break_acl_light
elif [[ "$LEVEL" == "medium" ]]; then
  break_fstab_medium
  break_nm_medium
  break_systemd_mask_medium
  break_firewalld_zone_medium
  break_selinux_port_medium
  break_acl_medium
else # heavy
  break_systemd_heavy
  break_pam_heavy
  break_sshd_heavy
  break_firewalld_heavy
  break_dns_heavy
  break_selinux_heavy
  break_users_heavy
fi

log "Done. Review manifest for hints: $manifest"
echo
echo "=== HINTS (from manifest) ==="
awk -F'|' '/^CHANGE\|/ {printf "- %s: %s\n  Fix: %s\n", $2, $3, $4}' "$manifest" || true