diff --git a/.gitignore b/.gitignore index b257325..37395a2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ __pycache__/ .ansible/ fact_cache/ *.swp +.DS_Store +.AppleDouble +.LSOverride +.Spotlight-V100 +.fseventsd diff --git a/inventories/.DS_Store b/inventories/.DS_Store new file mode 100644 index 0000000..a09d858 Binary files /dev/null and b/inventories/.DS_Store differ diff --git a/inventories/client_template/group_vars/pfsense.yml b/inventories/client_template/group_vars/pfsense.yml new file mode 100644 index 0000000..74e9411 --- /dev/null +++ b/inventories/client_template/group_vars/pfsense.yml @@ -0,0 +1,30 @@ +--- +# inventory/group_vars/pfsense.yml +# Applied to all hosts in the [pfsense] group. + +# pfSense runs FreeBSD — Python may not be installed. +# Using 'raw' module throughout the role avoids this entirely, +# but set the interpreter discovery to auto for safety. +ansible_python_interpreter: auto_silent + +# SSH connection settings tuned for pfSense/FreeBSD +ansible_connection: ssh +ansible_ssh_common_args: >- + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o ConnectTimeout=15 + -o ServerAliveInterval=10 + -o ServerAliveCountMax=3 + +# pfSense's shell is tcsh by default; force sh for compatibility +ansible_shell_type: sh +ansible_shell_executable: /bin/sh + +# Set to your SSH key or use ansible_password +# ansible_ssh_private_key_file: ~/.ssh/pfsense_rsa + +# Default upgrade settings (can be overridden per host in host_vars/) +perform_upgrade: false +allow_major_upgrade: false +auto_reboot: true +pkg_repo_update: true diff --git a/inventories/client_template/hosts.yml b/inventories/client_template/hosts.yml index 710f045..20e1b91 100644 --- a/inventories/client_template/hosts.yml +++ b/inventories/client_template/hosts.yml @@ -11,7 +11,7 @@ all: human_estimate_seconds: 2700 change_freeze: false ansible_ssh_extra_args: "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" - + children: # --- NETWORK LAYER --- firewalls: @@ -20,11 +20,14 @@ all: hosts: client-fw-01: ansible_host: "{{ FW_HOST }}" - + pfsense: hosts: client-fw-01: ansible_host: "{{ FW_HOST }}" + ansible_port: 22222 + ha_role: "primary" + #ha_peer: "client-fw-02" # Uncomment if this node is part an HA pair # --- INFRASTRUCTURE --- hypervisors: @@ -37,7 +40,7 @@ all: hosts: client-xcp-01: ansible_host: "{{ XCP_HOST }}" - + # --- WORKSTATIONS/SERVERS --- linux_hosts: hosts: {} @@ -54,4 +57,3 @@ all: ansible_winrm_transport: ntlm ansible_winrm_server_cert_validation: validate ansible_port: 5986 - diff --git a/inventories/clients/.DS_Store b/inventories/clients/.DS_Store new file mode 100644 index 0000000..59667d3 Binary files /dev/null and b/inventories/clients/.DS_Store differ diff --git a/inventories/clients/brenex/group_vars/pfsense.yml b/inventories/clients/brenex/group_vars/pfsense.yml new file mode 100644 index 0000000..74e9411 --- /dev/null +++ b/inventories/clients/brenex/group_vars/pfsense.yml @@ -0,0 +1,30 @@ +--- +# inventory/group_vars/pfsense.yml +# Applied to all hosts in the [pfsense] group. + +# pfSense runs FreeBSD — Python may not be installed. +# Using 'raw' module throughout the role avoids this entirely, +# but set the interpreter discovery to auto for safety. +ansible_python_interpreter: auto_silent + +# SSH connection settings tuned for pfSense/FreeBSD +ansible_connection: ssh +ansible_ssh_common_args: >- + -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null + -o ConnectTimeout=15 + -o ServerAliveInterval=10 + -o ServerAliveCountMax=3 + +# pfSense's shell is tcsh by default; force sh for compatibility +ansible_shell_type: sh +ansible_shell_executable: /bin/sh + +# Set to your SSH key or use ansible_password +# ansible_ssh_private_key_file: ~/.ssh/pfsense_rsa + +# Default upgrade settings (can be overridden per host in host_vars/) +perform_upgrade: false +allow_major_upgrade: false +auto_reboot: true +pkg_repo_update: true diff --git a/inventories/clients/brenex/hosts.yml b/inventories/clients/brenex/hosts.yml index b8ff2dd..2fe25c4 100644 --- a/inventories/clients/brenex/hosts.yml +++ b/inventories/clients/brenex/hosts.yml @@ -22,7 +22,9 @@ all: vendor: "pfsense" ansible_host: "fw.brenex.com" ansible_port: 22222 - + ha_role: "primary" + #ha_peer: "fw-ha-secondary" # Uncomment if this node is part of an HA pair + xcpng_pools: vars: ansible_become: false @@ -32,7 +34,7 @@ all: shared_storage: false upgrade_order: - brenex-pool-01 - + hosts: brenex-pool-01: ansible_host: 192.168.123.11 @@ -41,7 +43,7 @@ all: vars: ansible_user: root os_family: "debian" - + hosts: caddy-server: ansible_host: 192.168.123.16 @@ -52,7 +54,7 @@ all: ansible_host: 192.168.123.146 graylog-server: ansible_host: 192.168.123.16 - + windows_hosts: diff --git a/playbooks/pfsense_manage.yml b/playbooks/pfsense_manage.yml new file mode 100644 index 0000000..d4d3aa6 --- /dev/null +++ b/playbooks/pfsense_manage.yml @@ -0,0 +1,24 @@ +--- +# pfSense Upgrade Playbook +# Upgrades pfSense systems within their current version branch. +# Detects available stable releases and reports or applies upgrades. +# +# Usage: +# ansible-playbook upgrade.yml -i inventory/hosts.yml +# ansible-playbook upgrade.yml -i inventory/hosts.yml --tags check # dry-run only +# ansible-playbook upgrade.yml -i inventory/hosts.yml -e "perform_upgrade=true" +# ansible-playbook upgrade.yml -i inventory/hosts.yml -e "perform_upgrade=true allow_major_upgrade=true" + +- name: pfSense Upgrade + hosts: pfsense + gather_facts: false + serial: 1 # Upgrade one host at a time to preserve redundancy + + vars: + perform_upgrade: false # Safety gate — must be explicitly set to true + allow_major_upgrade: false # Set true to allow crossing major version branches + reboot_timeout: 300 # Seconds to wait for host after reboot + upgrade_check_timeout: 120 # Seconds before pfSense-upgrade check times out + + roles: + - pfsense_upgrade diff --git a/roles/.DS_Store b/roles/.DS_Store new file mode 100644 index 0000000..f57b2d2 Binary files /dev/null and b/roles/.DS_Store differ diff --git a/roles/hypervisor_backup_config/.DS_Store b/roles/hypervisor_backup_config/.DS_Store new file mode 100644 index 0000000..d85fa4a Binary files /dev/null and b/roles/hypervisor_backup_config/.DS_Store differ diff --git a/roles/linux_patch/.DS_Store b/roles/linux_patch/.DS_Store new file mode 100644 index 0000000..6b0fb4c Binary files /dev/null and b/roles/linux_patch/.DS_Store differ diff --git a/roles/pfsense_upgrade/README.md b/roles/pfsense_upgrade/README.md new file mode 100644 index 0000000..8a15b34 --- /dev/null +++ b/roles/pfsense_upgrade/README.md @@ -0,0 +1,139 @@ +# pfSense Ansible Upgrade Playbook + +Upgrades pfSense CE (and pfSense Plus) systems safely via Ansible. + +## Features + +- **Version detection** — reads `/etc/version`, parses branch, patch level, and release type +- **In-branch update check** — runs `pfSense-upgrade -c` to detect available patch releases within your running branch (e.g. 2.7.2 → 2.7.3) +- **New stable branch detection** — queries upstream GitHub for the latest stable version and warns if a newer branch (e.g. 2.8.x) has been released +- **Safety gates** — upgrade is a no-op unless `perform_upgrade=true` is explicitly passed +- **Branch-crossing protection** — requires `allow_major_upgrade=true` to upgrade across major/minor branches +- **Pre-upgrade config backup** — triggers pfSense's own PHP backup function before touching anything +- **Serial execution** — `serial: 1` ensures HA pairs are never upgraded simultaneously +- **Post-upgrade verification** — confirms version changed, services are listening, and the system is up-to-date + +--- + +## Requirements + +- Ansible ≥ 2.12 +- SSH enabled on pfSense (`System → Advanced → Admin Access → Enable Secure Shell`) +- Admin user with shell access +- The `raw` module is used throughout — **Python is not required on pfSense** + +--- + +## Quick Start + +### 1. Configure inventory + +Edit `inventory/hosts.yml` with your pfSense host IPs and SSH user: + +```yaml +fw-site-a: + ansible_host: 192.168.1.1 + ansible_user: admin +``` + +### 2. Configure SSH auth + +Either set a private key in `inventory/group_vars/pfsense.yml`: + +```yaml +ansible_ssh_private_key_file: ~/.ssh/pfsense_rsa +``` + +Or use password auth (add `ansible_password: yourpassword` per host in `host_vars/`). + +### 3. Check for available upgrades (safe, no changes made) + +```bash +ansible-playbook upgrade.yml +``` + +Or with explicit tag: + +```bash +ansible-playbook upgrade.yml --tags check +``` + +### 4. Apply in-branch upgrades + +```bash +ansible-playbook upgrade.yml -e "perform_upgrade=true" +``` + +### 5. Apply upgrade and allow branch crossing (e.g. 2.7 → 2.8) + +```bash +ansible-playbook upgrade.yml -e "perform_upgrade=true allow_major_upgrade=true" +``` + +### 6. Target a single host + +```bash +ansible-playbook upgrade.yml -l fw-site-a -e "perform_upgrade=true" +``` + +--- + +## Variables Reference + +| Variable | Default | Description | +|---|---|---| +| `perform_upgrade` | `false` | Safety gate — must be `true` to apply upgrades | +| `allow_major_upgrade` | `false` | Permit upgrades across branch boundaries | +| `auto_reboot` | `true` | Reboot automatically after upgrade | +| `reboot_timeout` | `300` | Seconds to wait for host after reboot | +| `upgrade_check_timeout` | `120` | Timeout for pfSense-upgrade check | +| `pkg_repo_update` | `true` | Run `pkg update -f` before checking | +| `skip_backup_check` | `false` | Skip pre-upgrade config backup step | +| `notify_webhook_url` | `""` | Optional Slack/Teams webhook for results | +| `pfsense_release_url` | GitHub raw URL | Where to fetch upstream latest version | + +--- + +## How Update Detection Works + +### In-branch updates +`pfSense-upgrade -d -c` is run on the host. It checks the configured pfSense pkg repository (same branch as running system) and returns non-zero if a newer package set is available. The playbook parses the exit code and output to determine availability. + +### New stable branch detection +The playbook fetches the `version` file from the pfSense CE GitHub repository (`master` branch) using FreeBSD `fetch`. The major.minor of the upstream version is compared to the running system's branch. If they differ and upstream is newer, a warning is displayed regardless of `perform_upgrade`. + +> Note: The GitHub master branch reflects the latest stable CE release. Adjust `pfsense_release_url` if you track a specific branch or use an internal mirror. + +--- + +## HA / CARP Considerations + +- `serial: 1` in the playbook ensures only one host upgrades at a time +- For CARP HA pairs, upgrade the **secondary first** so the primary continues to pass traffic +- Group your HA pairs in the inventory and order hosts accordingly +- Consider adding a task to demote the primary before upgrading if you need zero-downtime + +--- + +## File Structure + +``` +pfsense-upgrade/ +├── ansible.cfg +├── upgrade.yml # Main playbook entry point +├── inventory/ +│ ├── hosts.yml # Your pfSense hosts +│ └── group_vars/ +│ └── pfsense.yml # Shared SSH + default vars +└── roles/ + └── pfsense_upgrade/ + ├── defaults/ +│ └── main.yml # All default variable values + └── tasks/ + ├── main.yml # Task orchestration + ├── preflight.yml # SSH check, disk space, binary check + ├── version_detect.yml # Read and parse current version + ├── update_check.yml # pfSense-upgrade check + upstream compare + ├── upgrade.yml # Backup + execute upgrade + └── verify.yml # Post-reboot version + service check +``` diff --git a/roles/pfsense_upgrade/ansible.cfg b/roles/pfsense_upgrade/ansible.cfg new file mode 100644 index 0000000..47bf01a --- /dev/null +++ b/roles/pfsense_upgrade/ansible.cfg @@ -0,0 +1,12 @@ +[defaults] +inventory = inventory/hosts.yml +roles_path = roles +host_key_checking = False +timeout = 30 +forks = 1 # Never upgrade pfSense hosts in parallel +stdout_callback = yaml +callbacks_enabled = timer, profile_tasks + +[ssh_connection] +ssh_args = -o ControlMaster=no -o ControlPersist=no +pipelining = False # Disabled — pfSense/FreeBSD SSH may not support it diff --git a/roles/pfsense_upgrade/defaults/main.yml b/roles/pfsense_upgrade/defaults/main.yml new file mode 100644 index 0000000..8fa828e --- /dev/null +++ b/roles/pfsense_upgrade/defaults/main.yml @@ -0,0 +1,29 @@ +--- +# roles/pfsense_upgrade/defaults/main.yml +# Override any of these in group_vars, host_vars, or at the CLI with -e + +# --- Safety gates --- +perform_upgrade: false # Must explicitly set to true to apply upgrades +allow_major_upgrade: false # Set true to permit branch-crossing upgrades (e.g. 2.7 → 2.8) +skip_backup_check: false # Set true to skip the pre-upgrade config backup step + +# --- Upgrade behavior --- +auto_reboot: true # Reboot automatically after upgrade if required +reboot_timeout: 300 # Seconds to wait for host to come back after reboot +upgrade_check_timeout: 120 # Timeout for pfSense-upgrade version check +pkg_repo_update: true # Run pkg update before checking for upgrades + +# --- Notification --- +# Optional: set to a Slack/Teams webhook URL to post upgrade results +notify_webhook_url: "" + +# --- pfSense paths --- +pfsense_version_file: /etc/version +pfsense_version_patch_file: /etc/version.patch +pfsense_version_buildtime: /etc/version.buildtime +pfsense_upgrade_bin: /usr/local/sbin/pfSense-upgrade +pfsense_config_backup_path: /cf/conf/backup + +# --- Release tracking --- +# Netgate publishes release notes/versions at this URL (CE edition) +pfsense_release_url: "https://raw.githubusercontent.com/pfsense/pfsense/master/src/etc/version" diff --git a/roles/pfsense_upgrade/hosts.yml b/roles/pfsense_upgrade/hosts.yml new file mode 100644 index 0000000..3a0f04e --- /dev/null +++ b/roles/pfsense_upgrade/hosts.yml @@ -0,0 +1,29 @@ +--- +# inventory/hosts.yml +# Define your pfSense hosts here. +# Group them by site, role, or redundancy pair as needed. + +all: + children: + pfsense: + children: + + # --- Primary/standalone firewalls --- + pfsense_primary: + hosts: + fw-site-a: + ansible_host: 192.168.1.1 + ansible_user: admin + fw-site-b: + ansible_host: 10.10.0.1 + ansible_user: admin + + # --- HA pairs (upgrade sequentially — serial: 1 ensures this) --- + pfsense_ha_pair_1: + hosts: + fw-ha-primary: + ansible_host: 172.16.0.1 + ansible_user: admin + fw-ha-secondary: + ansible_host: 172.16.0.2 + ansible_user: admin diff --git a/roles/pfsense_upgrade/tasks/carp.yml b/roles/pfsense_upgrade/tasks/carp.yml new file mode 100644 index 0000000..56b6f56 --- /dev/null +++ b/roles/pfsense_upgrade/tasks/carp.yml @@ -0,0 +1,188 @@ +--- +# roles/pfsense_upgrade/tasks/carp.yml +# Handles CARP/HA awareness during upgrades. +# Only runs when ha_peer is defined. +# +# ha_role: backup → minimal pre-checks, upgrade proceeds normally +# ha_role: primary → full CARP state verification, forced demotion, peer version check + +# --------------------------------------------------------------------------- +# Backup node logic — runs on the backup before it upgrades +# --------------------------------------------------------------------------- +- name: "[CARP/backup] Verify primary peer is reachable before upgrading backup" + ansible.builtin.raw: echo "ping" + delegate_to: "{{ ha_peer }}" + register: _peer_reachable + failed_when: _peer_reachable.rc != 0 + when: ha_role == 'backup' + +- name: "[CARP/backup] Check current CARP state on this node" + ansible.builtin.raw: > + php -r "require_once('/etc/inc/interfaces.inc'); + \$carp = get_carp_status(); + echo \$carp;" + register: _backup_carp_state + changed_when: false + when: ha_role == 'backup' + +- name: "[CARP/backup] Display CARP state" + ansible.builtin.debug: + msg: "CARP state on {{ inventory_hostname }} (backup): {{ _backup_carp_state.stdout | trim }}" + when: ha_role == 'backup' + +# --------------------------------------------------------------------------- +# Primary node logic — runs on the primary before it upgrades +# --------------------------------------------------------------------------- + +# --- Step 1: Verify peer is reachable --- +- name: "[CARP/primary] Verify backup peer {{ ha_peer }} is reachable" + ansible.builtin.raw: echo "ping" + delegate_to: "{{ ha_peer }}" + register: _peer_reachable + failed_when: _peer_reachable.rc != 0 + when: + - ha_role == 'primary' + - ha_peer is defined + +# --- Step 2: Verify peer is on the upgraded version --- +- name: "[CARP/primary] Read version on backup peer {{ ha_peer }}" + ansible.builtin.raw: cat {{ pfsense_version_file }} + delegate_to: "{{ ha_peer }}" + register: _peer_version_raw + changed_when: false + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Set peer version fact" + ansible.builtin.set_fact: + ha_peer_version: "{{ _peer_version_raw.stdout | trim }}" + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Fail if backup peer is not on a newer version than primary" + ansible.builtin.fail: + msg: > + Backup peer {{ ha_peer }} is running {{ ha_peer_version }}, which is the same + as or older than this primary ({{ pfsense_current_version }}). + Upgrade the backup node first before proceeding with the primary. + when: + - ha_role == 'primary' + - ha_peer is defined + - ha_peer_version == pfsense_current_version + +# --- Step 3: Verify backup peer is in MASTER CARP state --- +- name: "[CARP/primary] Check CARP state on backup peer {{ ha_peer }}" + ansible.builtin.raw: > + ifconfig | grep -o 'carp: [A-Z]*' | awk '{print $2}' | sort -u + delegate_to: "{{ ha_peer }}" + register: _peer_carp_state_raw + changed_when: false + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Fail if backup peer is not MASTER" + ansible.builtin.fail: + msg: > + Backup peer {{ ha_peer }} CARP state is '{{ _peer_carp_state_raw.stdout | trim }}'. + Expected MASTER. Resolve CARP state before upgrading the primary. + when: + - ha_role == 'primary' + - ha_peer is defined + - "'MASTER' not in _peer_carp_state_raw.stdout" + +# --- Step 4: Force demotion of primary --- +- name: "[CARP/primary] Force CARP demotion on this node (set advskew to 254)" + ansible.builtin.raw: > + php -r " + require_once('/etc/inc/interfaces.inc'); + interfaces_carp_set_maintenancemode(true); + " + register: _carp_demotion + changed_when: true + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Wait for CARP failover to settle" + ansible.builtin.pause: + seconds: 15 + +# --- Step 5: Re-verify backup peer has taken MASTER --- +- name: "[CARP/primary] Re-check CARP state on backup peer after demotion" + ansible.builtin.raw: > + ifconfig | grep -o 'carp: [A-Z]*' | awk '{print $2}' | sort -u + delegate_to: "{{ ha_peer }}" + register: _peer_carp_recheck + changed_when: false + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Fail if backup peer did not take MASTER after demotion" + ansible.builtin.fail: + msg: > + Backup peer {{ ha_peer }} did not take MASTER after primary demotion. + CARP state is '{{ _peer_carp_recheck.stdout | trim }}'. + Investigate CARP before proceeding — primary has been demoted but backup is not MASTER. + when: + - ha_role == 'primary' + - ha_peer is defined + - "'MASTER' not in _peer_carp_recheck.stdout" + +- name: "[CARP/primary] CARP demotion confirmed — backup is MASTER, safe to upgrade primary" + ansible.builtin.debug: + msg: > + {{ ha_peer }} is MASTER. {{ inventory_hostname }} is demoted. + Proceeding with primary upgrade. + when: + - ha_role == 'primary' + - ha_peer is defined + +# --------------------------------------------------------------------------- +# Post-upgrade: restore CARP on primary and verify state +# --------------------------------------------------------------------------- +- name: "[CARP/primary] Re-enable CARP maintenance mode off (restore advskew)" + ansible.builtin.raw: > + php -r " + require_once('/etc/inc/interfaces.inc'); + interfaces_carp_set_maintenancemode(false); + " + register: _carp_restore + changed_when: true + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Wait for CARP state to stabilize after restore" + ansible.builtin.pause: + seconds: 20 + +- name: "[CARP/primary] Verify primary has reclaimed MASTER" + ansible.builtin.raw: > + ifconfig | grep -o 'carp: [A-Z]*' | awk '{print $2}' | sort -u + register: _primary_carp_final + changed_when: false + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Warn if primary did not reclaim MASTER" + ansible.builtin.debug: + msg: > + WARNING: Primary CARP state is '{{ _primary_carp_final.stdout | trim }}' — expected MASTER. + This may resolve on its own. Check CARP status on both nodes manually. + when: + - ha_role == 'primary' + - ha_peer is defined + - "'MASTER' not in _primary_carp_final.stdout" + +- name: "[CARP/primary] CARP state confirmed restored" + ansible.builtin.debug: + msg: "{{ inventory_hostname }} has reclaimed MASTER. HA pair is fully operational." + when: + - ha_role == 'primary' + - ha_peer is defined + - "'MASTER' in _primary_carp_final.stdout" \ No newline at end of file diff --git a/roles/pfsense_upgrade/tasks/main.yml b/roles/pfsense_upgrade/tasks/main.yml new file mode 100644 index 0000000..52ac2ed --- /dev/null +++ b/roles/pfsense_upgrade/tasks/main.yml @@ -0,0 +1,36 @@ +--- +# roles/pfsense_upgrade/tasks/main.yml + +- name: Include pre-flight checks + ansible.builtin.import_tasks: preflight.yml + tags: [always, preflight, check] + +- name: Include version detection + ansible.builtin.import_tasks: version_detect.yml + tags: [always, check] + +- name: Include update check + ansible.builtin.import_tasks: update_check.yml + tags: [always, check] + +- name: Include CARP/HA pre-upgrade logic + ansible.builtin.import_tasks: carp.yml + tags: [always, check, carp] + when: ha_peer is defined + +- name: Include upgrade execution + ansible.builtin.import_tasks: upgrade.yml + tags: [upgrade] + when: perform_upgrade | bool + +- name: Include CARP/HA post-upgrade restore + ansible.builtin.import_tasks: carp.yml + tags: [upgrade, carp] + when: + - ha_peer is defined + - perform_upgrade | bool + +- name: Include post-upgrade verification + ansible.builtin.import_tasks: verify.yml + tags: [upgrade, verify] + when: perform_upgrade | bool \ No newline at end of file diff --git a/roles/pfsense_upgrade/tasks/preflight.yml b/roles/pfsense_upgrade/tasks/preflight.yml new file mode 100644 index 0000000..3500ac7 --- /dev/null +++ b/roles/pfsense_upgrade/tasks/preflight.yml @@ -0,0 +1,41 @@ +--- +# roles/pfsense_upgrade/tasks/preflight.yml +# Validate SSH connectivity, confirm host is pfSense, check disk space. + +- name: Verify SSH connectivity to pfSense host + ansible.builtin.raw: echo "ping" + register: _ssh_test + changed_when: false + failed_when: _ssh_test.rc != 0 + +- name: Confirm host is running pfSense (check version file) + ansible.builtin.raw: test -f {{ pfsense_version_file }} && echo "pfsense_ok" + register: _pfsense_check + changed_when: false + failed_when: "'pfsense_ok' not in _pfsense_check.stdout" + +- name: Check available disk space on root filesystem (must be ≥ 200 MB) + ansible.builtin.raw: > + df -m / | awk 'NR==2 {print $4}' + register: _disk_avail + changed_when: false + +- name: Fail if disk space is insufficient + ansible.builtin.fail: + msg: > + Host {{ inventory_hostname }} only has {{ _disk_avail.stdout | trim }} MB free on /. + At least 200 MB is required to safely upgrade pfSense. + when: (_disk_avail.stdout | trim | int) < 200 + +- name: Check that pfSense-upgrade binary exists + ansible.builtin.raw: test -x {{ pfsense_upgrade_bin }} && echo "bin_ok" + register: _bin_check + changed_when: false + failed_when: "'bin_ok' not in _bin_check.stdout" + +- name: Pre-flight summary + ansible.builtin.debug: + msg: > + Pre-flight OK — {{ inventory_hostname }}: + disk free={{ _disk_avail.stdout | trim }}MB, + pfSense-upgrade binary present. diff --git a/roles/pfsense_upgrade/tasks/update_check.yml b/roles/pfsense_upgrade/tasks/update_check.yml new file mode 100644 index 0000000..caa0e48 --- /dev/null +++ b/roles/pfsense_upgrade/tasks/update_check.yml @@ -0,0 +1,149 @@ +--- +# roles/pfsense_upgrade/tasks/update_check.yml +# Checks for available upgrades using pfSense-upgrade -c and pkg version. +# Also queries upstream for the latest stable release on this branch. + +# --------------------------------------------------------------------------- +# 1. Refresh the local pkg repository metadata +# --------------------------------------------------------------------------- +- name: Update pkg repository metadata + ansible.builtin.raw: pkg update -f 2>&1 + register: _pkg_update + changed_when: false + when: pkg_repo_update | bool + timeout: "{{ upgrade_check_timeout }}" + +# --------------------------------------------------------------------------- +# 2. Run pfSense-upgrade in check-only mode +# --------------------------------------------------------------------------- +- name: Run pfSense-upgrade --check (dry run) + ansible.builtin.raw: > + {{ pfsense_upgrade_bin }} -d -c 2>&1 + register: _upgrade_check + changed_when: false + timeout: "{{ upgrade_check_timeout }}" + # pfSense-upgrade exits 0 when up-to-date, non-zero when upgrade available. + # We capture both cases. + failed_when: false + +- name: Parse upgrade check output + ansible.builtin.set_fact: + upgrade_check_stdout: "{{ _upgrade_check.stdout | trim }}" + upgrade_check_rc: "{{ _upgrade_check.rc }}" + # True if the tool reports an update is available + upgrade_available: >- + {{ + _upgrade_check.rc != 0 or + 'Upgraded' in _upgrade_check.stdout or + 'update' in _upgrade_check.stdout | lower and + 'up to date' not in _upgrade_check.stdout | lower + }} + # Attempt to extract the new version string from the upgrade check output + # pfSense-upgrade typically prints: "pfSense-upgrade: New version available: 2.7.3-RELEASE" + upgrade_available_version: >- + {{ + _upgrade_check.stdout + | regex_search('(\d+\.\d+\.\d+[-a-zA-Z0-9]*)', '\1') + | first | default('unknown') + }} + +# --------------------------------------------------------------------------- +# 3. Check pkg for pending package updates (captures sub-component updates) +# --------------------------------------------------------------------------- +- name: Check for pending pkg upgrades (outdated packages) + ansible.builtin.raw: pkg version -l '<' 2>&1 | head -40 + register: _pkg_outdated + changed_when: false + failed_when: false + +- name: Count outdated packages + ansible.builtin.set_fact: + pkg_outdated_count: "{{ _pkg_outdated.stdout_lines | reject('match', '^\\s*$') | list | length }}" + pkg_outdated_list: "{{ _pkg_outdated.stdout | trim }}" + +# --------------------------------------------------------------------------- +# 4. Detect the latest stable release for this branch via GitHub +# --------------------------------------------------------------------------- +- name: Fetch latest stable release version from Netgate/pfSense repo + ansible.builtin.raw: > + fetch -q -o - "{{ pfsense_release_url }}" 2>/dev/null || echo "fetch_failed" + register: _upstream_version_raw + changed_when: false + failed_when: false + +- name: Parse upstream latest stable version + ansible.builtin.set_fact: + upstream_version: "{{ _upstream_version_raw.stdout | trim }}" + upstream_fetch_ok: "{{ 'fetch_failed' not in _upstream_version_raw.stdout }}" + +- name: Derive upstream branch (major.minor) + ansible.builtin.set_fact: + upstream_major_minor: >- + {{ + upstream_version + | regex_replace('^(\d+\.\d+).*$', '\1') + | default(pfsense_major_minor) + }} + when: upstream_fetch_ok | bool + +# --------------------------------------------------------------------------- +# 5. Compare branches — detect if a newer stable branch exists upstream +# --------------------------------------------------------------------------- +- name: Determine if a newer major release branch is available + ansible.builtin.set_fact: + new_major_release_available: >- + {{ + upstream_fetch_ok | bool and + (upstream_major_minor | string) != (pfsense_major_minor | string) and + (upstream_major_minor.split('.')[0] | int > pfsense_major_minor.split('.')[0] | int) or + (upstream_major_minor.split('.')[0] | int == pfsense_major_minor.split('.')[0] | int and + upstream_major_minor.split('.')[1] | int > pfsense_major_minor.split('.')[1] | int) + }} + when: upstream_fetch_ok | bool + +- name: Default new_major_release_available when fetch failed + ansible.builtin.set_fact: + new_major_release_available: false + when: not (upstream_fetch_ok | bool) + +# --------------------------------------------------------------------------- +# 6. Print the full update status report +# --------------------------------------------------------------------------- +- name: Display update status report + ansible.builtin.debug: + msg: + - "============================================================" + - " Update Status: {{ inventory_hostname }}" + - "============================================================" + - " Current version : {{ pfsense_current_version }}" + - " Current branch : {{ pfsense_major_minor }}" + - "------------------------------------------------------------" + - " In-branch update : {{ 'YES — ' ~ upgrade_available_version if upgrade_available | bool else 'No — already up to date' }}" + - " Outdated pkgs : {{ pkg_outdated_count }} package(s) behind" + - "------------------------------------------------------------" + - " Upstream latest : {{ upstream_version if upstream_fetch_ok | bool else 'Could not reach upstream' }}" + - " Upstream branch : {{ upstream_major_minor if upstream_fetch_ok | bool else 'N/A' }}" + - " New branch avail : {{ 'YES — ' ~ upstream_version if new_major_release_available | bool else 'No' }}" + - "------------------------------------------------------------" + - " perform_upgrade : {{ perform_upgrade | bool }}" + - " allow_major_upg : {{ allow_major_upgrade | bool }}" + - "============================================================" + +- name: Warn if a new major release branch is available but not allowed + ansible.builtin.debug: + msg: > + WARNING: pfSense {{ upstream_version }} is available on branch {{ upstream_major_minor }}, + which is newer than your running branch {{ pfsense_major_minor }}. + To upgrade across branches, re-run with: -e "perform_upgrade=true allow_major_upgrade=true" + when: + - new_major_release_available | bool + - not (allow_major_upgrade | bool) + +- name: Warn if perform_upgrade is false but upgrades are available + ansible.builtin.debug: + msg: > + DRY RUN — upgrades are available but perform_upgrade=false. + Re-run with -e "perform_upgrade=true" to apply. + when: + - (upgrade_available | bool) or (pkg_outdated_count | int > 0) + - not (perform_upgrade | bool) diff --git a/roles/pfsense_upgrade/tasks/upgrade.yml b/roles/pfsense_upgrade/tasks/upgrade.yml new file mode 100644 index 0000000..adb750d --- /dev/null +++ b/roles/pfsense_upgrade/tasks/upgrade.yml @@ -0,0 +1,100 @@ +--- +# roles/pfsense_upgrade/tasks/upgrade.yml +# Applies the upgrade after safety checks pass. +# Only runs when perform_upgrade=true. + +- name: Abort if no upgrade is available (nothing to do) + ansible.builtin.debug: + msg: > + No in-branch upgrade is available for {{ inventory_hostname }}. + Current version {{ pfsense_current_version }} is already the latest on branch {{ pfsense_major_minor }}. + Skipping upgrade. + when: + - not (upgrade_available | bool) + - not (new_major_release_available | bool and allow_major_upgrade | bool) + +- name: End play for this host if nothing to upgrade + ansible.builtin.meta: end_host + when: + - not (upgrade_available | bool) + - not (new_major_release_available | bool and allow_major_upgrade | bool) + +# --------------------------------------------------------------------------- +# Branch-crossing guard +# --------------------------------------------------------------------------- +- name: Abort if major upgrade is available but not explicitly allowed + ansible.builtin.fail: + msg: > + A new pfSense branch is available ({{ upstream_version }}) but allow_major_upgrade=false. + Review the release notes for {{ upstream_version }} before upgrading across branches. + Re-run with -e "allow_major_upgrade=true" when ready. + when: + - new_major_release_available | bool + - not (allow_major_upgrade | bool) + - not (upgrade_available | bool) + +# --------------------------------------------------------------------------- +# Pre-upgrade config backup +# --------------------------------------------------------------------------- +- name: Trigger config backup via PHP (writes to /cf/conf/backup/) + ansible.builtin.raw: > + php -r "require_once('/etc/inc/config.inc'); + require_once('/etc/inc/util.inc'); + backup_config();" + register: _backup_result + changed_when: false + when: not (skip_backup_check | bool) + +- name: Confirm backup file was created + ansible.builtin.raw: > + ls -t {{ pfsense_config_backup_path }}/config-*.xml 2>/dev/null | head -1 + register: _backup_file + changed_when: false + when: not (skip_backup_check | bool) + +- name: Display backup file path + ansible.builtin.debug: + msg: "Config backup written to: {{ _backup_file.stdout | trim }}" + when: + - not (skip_backup_check | bool) + - _backup_file.stdout | trim | length > 0 + +- name: Warn if no backup file found + ansible.builtin.debug: + msg: > + WARNING: Could not confirm config backup was written. + Check {{ pfsense_config_backup_path }} manually before proceeding. + when: + - not (skip_backup_check | bool) + - _backup_file.stdout | trim | length == 0 + +# --------------------------------------------------------------------------- +# Execute the upgrade +# --------------------------------------------------------------------------- +- name: "UPGRADE — Running pfSense-upgrade on {{ inventory_hostname }}" + ansible.builtin.raw: > + {{ pfsense_upgrade_bin }} -d -y 2>&1 + register: _upgrade_result + async: 600 # pfSense upgrades can take several minutes + poll: 10 + timeout: 620 + # The upgrade reboots the host — the connection will drop. That is expected. + failed_when: > + _upgrade_result.rc is defined and + _upgrade_result.rc != 0 and + 'reboot' not in _upgrade_result.stdout | lower and + 'Restarting' not in _upgrade_result.stdout + +- name: Display upgrade output + ansible.builtin.debug: + msg: "{{ _upgrade_result.stdout_lines | default(['(no output captured — likely rebooted mid-stream)']) }}" + +# --------------------------------------------------------------------------- +# Wait for host to come back after reboot +# --------------------------------------------------------------------------- +- name: Wait for pfSense to reboot and become reachable + ansible.builtin.wait_for_connection: + delay: 30 + timeout: "{{ reboot_timeout }}" + sleep: 10 + when: auto_reboot | bool diff --git a/roles/pfsense_upgrade/tasks/verify.yml b/roles/pfsense_upgrade/tasks/verify.yml new file mode 100644 index 0000000..596120b --- /dev/null +++ b/roles/pfsense_upgrade/tasks/verify.yml @@ -0,0 +1,62 @@ +--- +# roles/pfsense_upgrade/tasks/verify.yml +# Verifies the system is healthy after upgrade and reports the new version. + +- name: Wait an additional grace period before verifying + ansible.builtin.pause: + seconds: 15 + +- name: Read post-upgrade version + ansible.builtin.raw: cat {{ pfsense_version_file }} + register: _new_version_raw + changed_when: false + retries: 3 + delay: 10 + +- name: Set post-upgrade version fact + ansible.builtin.set_fact: + pfsense_new_version: "{{ _new_version_raw.stdout | trim }}" + +- name: Verify pfSense web GUI is responding (port 443) + ansible.builtin.raw: > + fetch -q -o /dev/null --no-verify-peer https://127.0.0.1/ 2>&1 || true + register: _webgui_check + changed_when: false + failed_when: false + +- name: Check that key pfSense services are running + ansible.builtin.raw: > + sockstat -l | grep -E ':(53|443|80)\b' | wc -l | tr -d ' ' + register: _services_check + changed_when: false + failed_when: false + +- name: Run pfSense-upgrade --check post-upgrade (confirm up-to-date) + ansible.builtin.raw: > + {{ pfsense_upgrade_bin }} -d -c 2>&1 + register: _post_upgrade_check + changed_when: false + failed_when: false + +- name: Upgrade result summary + ansible.builtin.debug: + msg: + - "============================================================" + - " Upgrade Result: {{ inventory_hostname }}" + - "============================================================" + - " Previous version : {{ pfsense_current_version }}" + - " New version : {{ pfsense_new_version }}" + - " Version changed : {{ pfsense_current_version != pfsense_new_version }}" + - " Listening ports : {{ _services_check.stdout | trim }} found (DNS/HTTP/HTTPS)" + - " Post-upg check : {{ 'Up to date' if _post_upgrade_check.rc == 0 else 'May still have pending updates' }}" + - "============================================================" + +- name: Fail if version did not change after upgrade attempt + ansible.builtin.fail: + msg: > + pfSense version on {{ inventory_hostname }} is still {{ pfsense_new_version }} + after upgrade attempt (was {{ pfsense_current_version }}). + The upgrade may not have applied correctly — check the host manually. + when: + - pfsense_current_version == pfsense_new_version + - upgrade_available | bool diff --git a/roles/pfsense_upgrade/tasks/version_detect.yml b/roles/pfsense_upgrade/tasks/version_detect.yml new file mode 100644 index 0000000..a44db08 --- /dev/null +++ b/roles/pfsense_upgrade/tasks/version_detect.yml @@ -0,0 +1,70 @@ +--- +# roles/pfsense_upgrade/tasks/version_detect.yml +# Reads version info from the running pfSense host and sets facts. + +- name: Read current pfSense version string + ansible.builtin.raw: cat {{ pfsense_version_file }} + register: _raw_version + changed_when: false + +- name: Read patch level (if present) + ansible.builtin.raw: > + test -f {{ pfsense_version_patch_file }} && cat {{ pfsense_version_patch_file }} || echo "0" + register: _raw_patch + changed_when: false + +- name: Read build timestamp (if present) + ansible.builtin.raw: > + test -f {{ pfsense_version_buildtime }} && cat {{ pfsense_version_buildtime }} || echo "unknown" + register: _raw_buildtime + changed_when: false + +- name: Read pfSense edition (CE vs Plus) + ansible.builtin.raw: > + pkg info pfSense 2>/dev/null | grep -i '^Name' | awk '{print $3}' || echo "pfSense" + register: _raw_edition + changed_when: false + +- name: Detect CPU architecture + ansible.builtin.raw: uname -m + register: _raw_arch + changed_when: false + +- name: Set version facts + ansible.builtin.set_fact: + pfsense_current_version: "{{ _raw_version.stdout | trim }}" + pfsense_current_patch: "{{ _raw_patch.stdout | trim }}" + pfsense_build_time: "{{ _raw_buildtime.stdout | trim }}" + pfsense_edition: "{{ _raw_edition.stdout | trim }}" + pfsense_arch: "{{ _raw_arch.stdout | trim }}" + # Parse major.minor from version string (e.g. "2.7.2-RELEASE" → "2.7") + pfsense_major_minor: >- + {{ + (_raw_version.stdout | trim) + | regex_replace('^(\d+\.\d+).*$', '\1') + }} + # Parse patch version integer (e.g. "2.7.2-RELEASE" → 2) + pfsense_patch_int: >- + {{ + (_raw_version.stdout | trim) + | regex_replace('^(\d+)\.(\d+)\.(\d+).*$', '\3') + | default('0') + }} + # Determine if this is a RELEASE, RC, BETA, or ALPHA + pfsense_release_type: >- + {{ + (_raw_version.stdout | trim) + | regex_replace('^.*-(RELEASE|RC\d*|BETA\d*|ALPHA\d*|DEVELOPMENT).*$', '\1') + | default('UNKNOWN') + }} + +- name: Display detected version info + ansible.builtin.debug: + msg: + - "Host : {{ inventory_hostname }}" + - "Edition : {{ pfsense_edition }}" + - "Version : {{ pfsense_current_version }}" + - "Branch : {{ pfsense_major_minor }}" + - "Release type : {{ pfsense_release_type }}" + - "Build time : {{ pfsense_build_time }}" + - "Architecture : {{ pfsense_arch }}" diff --git a/roles/preflight/.DS_Store b/roles/preflight/.DS_Store new file mode 100644 index 0000000..fc93f86 Binary files /dev/null and b/roles/preflight/.DS_Store differ diff --git a/roles/proxmox_ceph/.DS_Store b/roles/proxmox_ceph/.DS_Store new file mode 100644 index 0000000..f125855 Binary files /dev/null and b/roles/proxmox_ceph/.DS_Store differ diff --git a/roles/proxmox_config_backup/.DS_Store b/roles/proxmox_config_backup/.DS_Store new file mode 100644 index 0000000..ba139b6 Binary files /dev/null and b/roles/proxmox_config_backup/.DS_Store differ diff --git a/roles/proxmox_drain/.DS_Store b/roles/proxmox_drain/.DS_Store new file mode 100644 index 0000000..c36b7e5 Binary files /dev/null and b/roles/proxmox_drain/.DS_Store differ diff --git a/roles/proxmox_ha/.DS_Store b/roles/proxmox_ha/.DS_Store new file mode 100644 index 0000000..3be1ad2 Binary files /dev/null and b/roles/proxmox_ha/.DS_Store differ diff --git a/roles/proxmox_preflight/.DS_Store b/roles/proxmox_preflight/.DS_Store new file mode 100644 index 0000000..6be5fdf Binary files /dev/null and b/roles/proxmox_preflight/.DS_Store differ diff --git a/roles/proxmox_restore/.DS_Store b/roles/proxmox_restore/.DS_Store new file mode 100644 index 0000000..df9ed2d Binary files /dev/null and b/roles/proxmox_restore/.DS_Store differ diff --git a/roles/proxmox_status/.DS_Store b/roles/proxmox_status/.DS_Store new file mode 100644 index 0000000..bea7133 Binary files /dev/null and b/roles/proxmox_status/.DS_Store differ diff --git a/roles/proxmox_upgrade/.DS_Store b/roles/proxmox_upgrade/.DS_Store new file mode 100644 index 0000000..831df11 Binary files /dev/null and b/roles/proxmox_upgrade/.DS_Store differ diff --git a/roles/proxmox_upgrade_node/.DS_Store b/roles/proxmox_upgrade_node/.DS_Store new file mode 100644 index 0000000..397817d Binary files /dev/null and b/roles/proxmox_upgrade_node/.DS_Store differ diff --git a/roles/report/.DS_Store b/roles/report/.DS_Store new file mode 100644 index 0000000..f51eb16 Binary files /dev/null and b/roles/report/.DS_Store differ diff --git a/roles/snapshot/.DS_Store b/roles/snapshot/.DS_Store new file mode 100644 index 0000000..07a4551 Binary files /dev/null and b/roles/snapshot/.DS_Store differ diff --git a/roles/windows_patch/.DS_Store b/roles/windows_patch/.DS_Store new file mode 100644 index 0000000..bfa5356 Binary files /dev/null and b/roles/windows_patch/.DS_Store differ