mirror of
https://github.com/opnsense/plugins.git
synced 2026-02-19 02:29:23 -05:00
Merge 5631c07e3d into 1bfb448cf2
This commit is contained in:
commit
2cddd34fa2
26 changed files with 3834 additions and 0 deletions
8
sysutils/autorollback/Makefile
Normal file
8
sysutils/autorollback/Makefile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
PLUGIN_NAME= autorollback
|
||||
PLUGIN_VERSION= 1.0
|
||||
PLUGIN_COMMENT= Automatic configuration rollback with safe mode
|
||||
PLUGIN_MAINTAINER= github.immobile762@passmail.net
|
||||
PLUGIN_WWW= https://github.com/mplind/os-autorollback
|
||||
PLUGIN_TIER= 2
|
||||
|
||||
.include "../../Mk/plugins.mk"
|
||||
26
sysutils/autorollback/pkg-descr
Normal file
26
sysutils/autorollback/pkg-descr
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
Automatic configuration rollback plugin for OPNsense.
|
||||
|
||||
Provides a "Safe Mode" that snapshots the current configuration before
|
||||
changes are made. If the administrator does not confirm the changes within
|
||||
a configurable timeout, the system automatically reverts to the previous
|
||||
known-good configuration.
|
||||
|
||||
Features:
|
||||
|
||||
* Timer-based auto-revert with configurable timeout (default 120 seconds)
|
||||
* Persistent countdown banner in the web UI for confirmation
|
||||
* CLI confirmation via configctl for SSH users
|
||||
* Always-on connectivity watchdog with configurable health checks
|
||||
* Crash-safe: survives reboots via early boot recovery
|
||||
* Dashboard widget showing real-time status
|
||||
* Git backup integration (if os-git-backup is installed)
|
||||
* Configurable rollback method: full reboot, service reload, or targeted restart
|
||||
|
||||
Inspired by Juniper JUNOS "commit confirmed" and MikroTik Safe Mode.
|
||||
|
||||
Plugin Changelog
|
||||
================
|
||||
|
||||
1.0
|
||||
|
||||
* Initial release
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 MP Lindsey
|
||||
*
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register cron jobs for the auto-rollback watchdog.
|
||||
* The watchdog runs every minute to:
|
||||
* 1. Check if safe mode timer expired (cron safety net)
|
||||
* 2. Run connectivity health checks (if watchdog is enabled)
|
||||
*
|
||||
* @return array cron job definitions
|
||||
*/
|
||||
function autorollback_cron()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'autocron' => [
|
||||
'/usr/local/sbin/configctl autorollback watchdog.check',
|
||||
'*/1', // Every minute
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the auto-rollback service for the service manager.
|
||||
* This allows starting/stopping/status via the Services page and API.
|
||||
*
|
||||
* @return array service definitions
|
||||
*/
|
||||
function autorollback_services()
|
||||
{
|
||||
$mdl = new \OPNsense\AutoRollback\AutoRollback();
|
||||
|
||||
$services = [];
|
||||
|
||||
if ((string)$mdl->general->Enabled == '1') {
|
||||
$services[] = [
|
||||
'description' => gettext('Auto Rollback Safe Mode'),
|
||||
'configd' => [
|
||||
'restart' => ['autorollback safemode.start'],
|
||||
'start' => ['autorollback safemode.start'],
|
||||
'stop' => ['autorollback safemode.cancel'],
|
||||
],
|
||||
'name' => 'autorollback',
|
||||
'nocheck' => true, // No PID file to check — uses state files
|
||||
];
|
||||
}
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register syslog facility for auto-rollback events.
|
||||
*
|
||||
* @return array syslog configuration
|
||||
*/
|
||||
function autorollback_syslog()
|
||||
{
|
||||
return [
|
||||
'autorollback' => [
|
||||
'facility' => ['autorollback', 'autorollback-recovery'],
|
||||
],
|
||||
];
|
||||
}
|
||||
131
sysutils/autorollback/src/etc/rc.syshook.d/config/50-autorollback
Executable file
131
sysutils/autorollback/src/etc/rc.syshook.d/config/50-autorollback
Executable file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Config Change Hook (syshook/config)
|
||||
|
||||
This script is called by OPNsense every time config.xml is saved.
|
||||
It receives the backup file path as its first argument.
|
||||
|
||||
Purpose:
|
||||
1. Record the config change for the connectivity watchdog
|
||||
2. Record BOTH the new backup AND the previous backup (for correct rollback target)
|
||||
3. Skip recording if a rollback restore is in progress (re-entrancy guard)
|
||||
4. Skip recording if a firmware update is in progress
|
||||
|
||||
This script MUST be fast and lightweight — it runs synchronously
|
||||
in the config save pipeline.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import glob
|
||||
import re
|
||||
|
||||
# Paths
|
||||
VOLATILE_DIR = '/var/run/autorollback'
|
||||
RESTORE_LOCK = os.path.join(VOLATILE_DIR, 'restoring.lock')
|
||||
LAST_CONFIG_FILE = os.path.join(VOLATILE_DIR, 'last_config_change')
|
||||
FIRMWARE_LOCK = '/tmp/pkg_upgrade.progress'
|
||||
CONFIG_BACKUP_DIR = '/conf/backup'
|
||||
|
||||
# Same regex as common.py to match only timestamped backups
|
||||
BACKUP_TIMESTAMP_RE = re.compile(r'^config-\d+(\.\d+)?(_\d+)?\.xml$')
|
||||
|
||||
|
||||
def get_previous_backup(current_backup):
|
||||
"""
|
||||
Find the backup file that existed BEFORE the current one.
|
||||
This is the correct rollback target for the watchdog.
|
||||
"""
|
||||
try:
|
||||
backups = glob.glob(os.path.join(CONFIG_BACKUP_DIR, 'config-*.xml'))
|
||||
backups = [b for b in backups if BACKUP_TIMESTAMP_RE.match(os.path.basename(b))]
|
||||
backups.sort()
|
||||
if current_backup and current_backup in backups:
|
||||
idx = backups.index(current_backup)
|
||||
if idx > 0:
|
||||
return backups[idx - 1]
|
||||
elif len(backups) >= 2:
|
||||
# Current backup might not be in the list yet, return second-to-last
|
||||
return backups[-2]
|
||||
except Exception:
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
def main():
|
||||
# Get backup file path from argument
|
||||
backup_file = sys.argv[1] if len(sys.argv) > 1 else ''
|
||||
|
||||
# Re-entrancy guard: skip if we're restoring a config
|
||||
if os.path.isfile(RESTORE_LOCK):
|
||||
# Check if lock is actually held (not stale)
|
||||
import fcntl
|
||||
fd = None
|
||||
try:
|
||||
fd = open(RESTORE_LOCK, 'r')
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# Got lock = stale file, clean up
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
try:
|
||||
os.unlink(RESTORE_LOCK)
|
||||
except OSError:
|
||||
pass
|
||||
except (BlockingIOError, OSError):
|
||||
# Lock held = restore in progress, skip
|
||||
return
|
||||
finally:
|
||||
if fd is not None:
|
||||
fd.close()
|
||||
|
||||
# Skip during firmware updates
|
||||
if os.path.isfile(FIRMWARE_LOCK):
|
||||
return
|
||||
|
||||
# Ensure volatile directory exists
|
||||
os.makedirs(VOLATILE_DIR, mode=0o750, exist_ok=True)
|
||||
|
||||
# Find the previous backup (the one BEFORE this config change)
|
||||
previous_backup = get_previous_backup(backup_file)
|
||||
|
||||
# Record the config change for the watchdog
|
||||
try:
|
||||
state = {
|
||||
'time': time.time(),
|
||||
'backup': backup_file,
|
||||
'previous_backup': previous_backup,
|
||||
}
|
||||
with open(LAST_CONFIG_FILE, 'w') as f:
|
||||
json.dump(state, f)
|
||||
except (IOError, OSError):
|
||||
pass # Non-critical — don't break the config save pipeline
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
203
sysutils/autorollback/src/etc/rc.syshook.d/early/10-autorollback-recovery
Executable file
203
sysutils/autorollback/src/etc/rc.syshook.d/early/10-autorollback-recovery
Executable file
|
|
@ -0,0 +1,203 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Early Boot Recovery (syshook/early)
|
||||
|
||||
This is the TERTIARY rollback trigger — the last line of defense.
|
||||
It runs during early boot, BEFORE networking starts.
|
||||
|
||||
Scenario:
|
||||
1. Admin enters safe mode
|
||||
2. Makes a config change that breaks something
|
||||
3. System crashes or reboots (or admin reboots to try to fix)
|
||||
4. System starts booting with the BAD config
|
||||
5. THIS SCRIPT fires before networking starts
|
||||
6. Detects the persistent state file with an expired timer
|
||||
7. Restores the known-good config.xml BEFORE any service reads it
|
||||
8. System boots with the known-good config
|
||||
|
||||
The persistent state is stored at /conf/autorollback_pending.json
|
||||
(on persistent storage, NOT tmpfs).
|
||||
|
||||
This script must be FAST and SELF-CONTAINED — no external dependencies
|
||||
beyond Python stdlib and the config file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import syslog
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
PERSISTENT_STATE = '/conf/autorollback_pending.json'
|
||||
CONFIG_XML = '/conf/config.xml'
|
||||
CONFIG_CACHE = '/tmp/config.cache'
|
||||
CONFIG_BACKUP_DIR = '/conf/backup'
|
||||
|
||||
|
||||
syslog.openlog('autorollback-recovery', syslog.LOG_PID, syslog.LOG_LOCAL4)
|
||||
|
||||
def log(msg):
|
||||
"""Log to syslog."""
|
||||
try:
|
||||
syslog.syslog(syslog.LOG_WARNING, msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def validate_config(path):
|
||||
"""Quick validation of a config.xml file."""
|
||||
try:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
return (root.tag in ('opnsense', 'pfsense')
|
||||
and root.find('system') is not None
|
||||
and root.find('interfaces') is not None)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def validate_backup_path(path):
|
||||
"""Validate that backup_file is within allowed directories (defense-in-depth)."""
|
||||
allowed = (CONFIG_BACKUP_DIR, os.path.dirname(CONFIG_XML))
|
||||
try:
|
||||
real = os.path.realpath(path)
|
||||
for d in allowed:
|
||||
real_d = os.path.realpath(d)
|
||||
if real.startswith(real_d + os.sep) or real == real_d:
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
# Check for persistent state file
|
||||
if not os.path.isfile(PERSISTENT_STATE):
|
||||
return # No pending rollback — normal boot
|
||||
|
||||
try:
|
||||
with open(PERSISTENT_STATE, 'r') as f:
|
||||
state = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return # Corrupt state file — skip
|
||||
|
||||
# Only act on safe mode states
|
||||
if state.get('mode') != 'safemode':
|
||||
return
|
||||
|
||||
# Check if the timer has expired
|
||||
expiry = state.get('expiry_time', 0)
|
||||
now = time.time()
|
||||
|
||||
if now < expiry:
|
||||
# Timer hasn't expired — don't rollback yet
|
||||
# The timer daemon will handle it when cron starts
|
||||
return
|
||||
|
||||
# Timer expired! This means:
|
||||
# - The system rebooted/crashed during safe mode
|
||||
# - The timer daemon never got to fire (it was in /var/run, which is tmpfs)
|
||||
# - We need to restore the known-good config NOW, before services start
|
||||
|
||||
backup_file = state.get('backup_file', '')
|
||||
if not backup_file or not validate_backup_path(backup_file) or not os.path.isfile(backup_file):
|
||||
log('EARLY BOOT RECOVERY: Expired safe mode found but backup missing or invalid path: %s' % backup_file)
|
||||
# Clean up the stale state
|
||||
try:
|
||||
os.unlink(PERSISTENT_STATE)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
# Validate the backup
|
||||
if not validate_config(backup_file):
|
||||
log('EARLY BOOT RECOVERY: Backup file is invalid: %s' % backup_file)
|
||||
try:
|
||||
os.unlink(PERSISTENT_STATE)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
# --- PERFORM EARLY BOOT ROLLBACK ---
|
||||
log('=== EARLY BOOT RECOVERY: Safe mode expired %d seconds ago. Restoring config from %s ===' % (
|
||||
int(now - expiry), backup_file))
|
||||
|
||||
try:
|
||||
# Create safety backup of current (bad) config
|
||||
safety = os.path.join(CONFIG_BACKUP_DIR, 'config-pre-boot-recovery.xml')
|
||||
if os.path.isfile(CONFIG_XML):
|
||||
shutil.copy2(CONFIG_XML, safety)
|
||||
|
||||
# Capture original ownership
|
||||
try:
|
||||
st = os.stat(CONFIG_XML)
|
||||
orig_uid, orig_gid = st.st_uid, st.st_gid
|
||||
except OSError:
|
||||
orig_uid, orig_gid = 0, 0
|
||||
|
||||
# Restore the known-good config atomically via temp + rename
|
||||
conf_dir = os.path.dirname(CONFIG_XML)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=conf_dir, prefix='.config_recovery_')
|
||||
try:
|
||||
os.close(fd)
|
||||
shutil.copy2(backup_file, tmp_path)
|
||||
os.chmod(tmp_path, 0o640)
|
||||
try:
|
||||
os.chown(tmp_path, orig_uid, orig_gid)
|
||||
except PermissionError:
|
||||
pass
|
||||
os.rename(tmp_path, CONFIG_XML)
|
||||
except Exception:
|
||||
# Clean up temp file on failure
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# Remove config cache
|
||||
if os.path.isfile(CONFIG_CACHE):
|
||||
os.unlink(CONFIG_CACHE)
|
||||
|
||||
log('EARLY BOOT RECOVERY: Config restored successfully. System will boot with known-good config.')
|
||||
|
||||
# Only clean up persistent state on successful recovery
|
||||
try:
|
||||
os.unlink(PERSISTENT_STATE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
log('EARLY BOOT RECOVERY FAILED: %s — state preserved for retry on next boot' % str(e))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 MP Lindsey
|
||||
*
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
namespace OPNsense\AutoRollback\Api;
|
||||
|
||||
use OPNsense\Base\ApiControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
|
||||
/**
|
||||
* Service API controller - handles safe mode operations and status.
|
||||
*
|
||||
* API Endpoints:
|
||||
* POST /api/autorollback/service/start - Enter safe mode
|
||||
* POST /api/autorollback/service/confirm - Confirm changes
|
||||
* POST /api/autorollback/service/cancel - Cancel and rollback
|
||||
* POST /api/autorollback/service/extend - Extend timer
|
||||
* GET /api/autorollback/service/status - Get current status
|
||||
*/
|
||||
class ServiceController extends ApiControllerBase
|
||||
{
|
||||
/**
|
||||
* Start safe mode - snapshot current config and begin countdown.
|
||||
*
|
||||
* @return array result
|
||||
*/
|
||||
public function startAction()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$backend = new Backend();
|
||||
|
||||
// Optional custom timeout from POST body
|
||||
$timeout = $this->request->getPost('timeout', 'int', null);
|
||||
$param = $timeout ? (string)$timeout : '';
|
||||
|
||||
$response = $backend->configdpRun('autorollback safemode.start', [$param]);
|
||||
$result = json_decode(trim($response), true);
|
||||
|
||||
if ($result === null) {
|
||||
return ['status' => 'error', 'message' => 'Backend returned invalid response'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
return ['status' => 'error', 'message' => 'POST required'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm safe mode changes - accept the configuration.
|
||||
*
|
||||
* @return array result
|
||||
*/
|
||||
public function confirmAction()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$backend = new Backend();
|
||||
$response = $backend->configdRun('autorollback safemode.confirm');
|
||||
$result = json_decode(trim($response), true);
|
||||
|
||||
if ($result === null) {
|
||||
return ['status' => 'error', 'message' => 'Backend returned invalid response'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
return ['status' => 'error', 'message' => 'POST required'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel safe mode - rollback to previous config immediately.
|
||||
*
|
||||
* @return array result
|
||||
*/
|
||||
public function cancelAction()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$backend = new Backend();
|
||||
$response = $backend->configdRun('autorollback safemode.cancel');
|
||||
$result = json_decode(trim($response), true);
|
||||
|
||||
if ($result === null) {
|
||||
return ['status' => 'error', 'message' => 'Backend returned invalid response'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
return ['status' => 'error', 'message' => 'POST required'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the safe mode countdown timer.
|
||||
*
|
||||
* @return array result
|
||||
*/
|
||||
public function extendAction()
|
||||
{
|
||||
if ($this->request->isPost()) {
|
||||
$backend = new Backend();
|
||||
|
||||
$seconds = $this->request->getPost('seconds', 'int', 60);
|
||||
$response = $backend->configdpRun('autorollback safemode.extend', [(string)$seconds]);
|
||||
$result = json_decode(trim($response), true);
|
||||
|
||||
if ($result === null) {
|
||||
return ['status' => 'error', 'message' => 'Backend returned invalid response'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
return ['status' => 'error', 'message' => 'POST required'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auto-rollback status.
|
||||
*
|
||||
* @return array status information
|
||||
*/
|
||||
public function statusAction()
|
||||
{
|
||||
$backend = new Backend();
|
||||
$response = $backend->configdRun('autorollback status');
|
||||
$result = json_decode(trim($response), true);
|
||||
|
||||
if ($result === null) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'message' => 'Backend returned invalid response',
|
||||
'system_state' => 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 MP Lindsey
|
||||
*
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
namespace OPNsense\AutoRollback\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
|
||||
/**
|
||||
* Settings API controller - handles get/set of plugin configuration.
|
||||
* Inherits searchAction, getAction, setAction from base class.
|
||||
*/
|
||||
class SettingsController extends ApiMutableModelControllerBase
|
||||
{
|
||||
protected static $internalModelClass = 'OPNsense\AutoRollback\AutoRollback';
|
||||
protected static $internalModelName = 'autorollback';
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 MP Lindsey
|
||||
*
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
namespace OPNsense\AutoRollback;
|
||||
|
||||
class IndexController extends \OPNsense\Base\IndexController
|
||||
{
|
||||
/**
|
||||
* Main settings page.
|
||||
*/
|
||||
public function indexAction()
|
||||
{
|
||||
$this->view->pick('OPNsense/AutoRollback/index');
|
||||
$this->view->generalForm = $this->getForm('general');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<form>
|
||||
<field>
|
||||
<id>autorollback.general.Enabled</id>
|
||||
<label>Enable Auto Rollback</label>
|
||||
<type>checkbox</type>
|
||||
<help>Enable the auto-rollback safe mode and connectivity watchdog features.</help>
|
||||
</field>
|
||||
<field>
|
||||
<type>header</type>
|
||||
<label>Safe Mode Settings</label>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.SafeModeTimeout</id>
|
||||
<label>Default Timeout (seconds)</label>
|
||||
<type>text</type>
|
||||
<help>How many seconds to wait for confirmation before automatically rolling back. Default: 120 seconds. Range: 30-3600.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.RollbackMethod</id>
|
||||
<label>Rollback Method</label>
|
||||
<type>dropdown</type>
|
||||
<help>How to apply the restored configuration. Full reboot is most reliable (recommended). Service reload is faster but may not apply kernel tunables or interface changes.</help>
|
||||
</field>
|
||||
<field>
|
||||
<type>header</type>
|
||||
<label>Connectivity Watchdog</label>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.WatchdogEnabled</id>
|
||||
<label>Enable Watchdog</label>
|
||||
<type>checkbox</type>
|
||||
<help>Enable the always-on connectivity watchdog. Monitors system health after config changes and auto-reverts if connectivity is lost.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.WatchdogGracePeriod</id>
|
||||
<label>Grace Period (seconds)</label>
|
||||
<type>text</type>
|
||||
<help>Seconds to wait after a config change before running health checks. Allows services time to restart. Default: 60 seconds.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.WatchdogFailThreshold</id>
|
||||
<label>Failure Threshold</label>
|
||||
<type>text</type>
|
||||
<help>Number of consecutive failed health checks before triggering a rollback. Default: 3.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.WatchdogCheckCommand</id>
|
||||
<label>Primary Check Command</label>
|
||||
<type>text</type>
|
||||
<help>Shell command to run for connectivity verification. Use %gateway% as placeholder for the default gateway IP. Default: ping -c 1 -W 3 -t 5 %gateway%</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.WatchdogCheckPattern</id>
|
||||
<label>Primary Check Pattern</label>
|
||||
<type>text</type>
|
||||
<help>Regex pattern to match in the check command output for a successful result. Default: "1 packets received"</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.WatchdogCheckCommand2</id>
|
||||
<label>Secondary Check Command (optional)</label>
|
||||
<type>text</type>
|
||||
<help>Optional second health check command. Example: host google.com (DNS resolution test). Leave empty to disable.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.WatchdogCheckPattern2</id>
|
||||
<label>Secondary Check Pattern</label>
|
||||
<type>text</type>
|
||||
<help>Regex pattern for the secondary check command.</help>
|
||||
</field>
|
||||
<field>
|
||||
<type>header</type>
|
||||
<label>Logging</label>
|
||||
</field>
|
||||
<field>
|
||||
<id>autorollback.general.LogRollbacks</id>
|
||||
<label>Log Events</label>
|
||||
<type>checkbox</type>
|
||||
<help>Log all safe mode and rollback events to syslog.</help>
|
||||
</field>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<acl>
|
||||
<page-autorollback>
|
||||
<name>WebCfg - Auto Rollback: Settings</name>
|
||||
<patterns>
|
||||
<pattern>ui/autorollback/*</pattern>
|
||||
<pattern>api/autorollback/*</pattern>
|
||||
</patterns>
|
||||
</page-autorollback>
|
||||
</acl>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2026 MP Lindsey
|
||||
*
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
namespace OPNsense\AutoRollback;
|
||||
|
||||
use OPNsense\Base\BaseModel;
|
||||
|
||||
class AutoRollback extends BaseModel
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<model>
|
||||
<mount>//OPNsense/autorollback</mount>
|
||||
<version>1.0.0</version>
|
||||
<description>Auto Rollback configuration</description>
|
||||
<items>
|
||||
<general>
|
||||
<!-- Master enable/disable for the entire plugin -->
|
||||
<Enabled type="BooleanField">
|
||||
<Default>0</Default>
|
||||
<Required>Y</Required>
|
||||
</Enabled>
|
||||
|
||||
<!-- Safe Mode Settings -->
|
||||
<SafeModeTimeout type="IntegerField">
|
||||
<Default>120</Default>
|
||||
<MinimumValue>30</MinimumValue>
|
||||
<MaximumValue>3600</MaximumValue>
|
||||
<ValidationMessage>Timeout must be between 30 and 3600 seconds.</ValidationMessage>
|
||||
</SafeModeTimeout>
|
||||
|
||||
<!-- What to do when rolling back -->
|
||||
<RollbackMethod type="OptionField">
|
||||
<Default>reboot</Default>
|
||||
<OptionValues>
|
||||
<reboot>Full reboot (most reliable, recommended)</reboot>
|
||||
<reload>Service reload (faster, may miss kernel tunables)</reload>
|
||||
</OptionValues>
|
||||
</RollbackMethod>
|
||||
|
||||
<!-- Connectivity Watchdog Settings -->
|
||||
<WatchdogEnabled type="BooleanField">
|
||||
<Default>0</Default>
|
||||
<Required>Y</Required>
|
||||
</WatchdogEnabled>
|
||||
|
||||
<!-- Grace period after config change before watchdog checks begin (seconds) -->
|
||||
<WatchdogGracePeriod type="IntegerField">
|
||||
<Default>60</Default>
|
||||
<MinimumValue>15</MinimumValue>
|
||||
<MaximumValue>600</MaximumValue>
|
||||
<ValidationMessage>Grace period must be between 15 and 600 seconds.</ValidationMessage>
|
||||
</WatchdogGracePeriod>
|
||||
|
||||
<!-- Number of consecutive failed checks before rollback -->
|
||||
<WatchdogFailThreshold type="IntegerField">
|
||||
<Default>3</Default>
|
||||
<MinimumValue>1</MinimumValue>
|
||||
<MaximumValue>10</MaximumValue>
|
||||
<ValidationMessage>Fail threshold must be between 1 and 10.</ValidationMessage>
|
||||
</WatchdogFailThreshold>
|
||||
|
||||
<!-- Health check command - the command to run for connectivity verification -->
|
||||
<WatchdogCheckCommand type="TextField">
|
||||
<Default>ping -c 1 -W 3 -t 5 %gateway%</Default>
|
||||
<Required>Y</Required>
|
||||
<Mask>/^.{1,512}$/</Mask>
|
||||
<ValidationMessage>Check command must be 1-512 characters.</ValidationMessage>
|
||||
</WatchdogCheckCommand>
|
||||
|
||||
<!-- Expected response pattern (regex) for the check command -->
|
||||
<WatchdogCheckPattern type="TextField">
|
||||
<Default>1 packets received</Default>
|
||||
<Required>Y</Required>
|
||||
<Mask>/^.{1,256}$/</Mask>
|
||||
<ValidationMessage>Check pattern must be 1-256 characters.</ValidationMessage>
|
||||
</WatchdogCheckPattern>
|
||||
|
||||
<!-- Secondary check command (optional - e.g., DNS resolution) -->
|
||||
<WatchdogCheckCommand2 type="TextField">
|
||||
<Default></Default>
|
||||
<Required>N</Required>
|
||||
<Mask>/^.{0,512}$/</Mask>
|
||||
</WatchdogCheckCommand2>
|
||||
|
||||
<!-- Secondary check pattern -->
|
||||
<WatchdogCheckPattern2 type="TextField">
|
||||
<Default></Default>
|
||||
<Required>N</Required>
|
||||
<Mask>/^.{0,256}$/</Mask>
|
||||
</WatchdogCheckPattern2>
|
||||
|
||||
<!-- Notification: log rollback events to syslog -->
|
||||
<LogRollbacks type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</LogRollbacks>
|
||||
</general>
|
||||
</items>
|
||||
</model>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<menu>
|
||||
<System order="95">
|
||||
<AutoRollback VisibleName="Auto Rollback" cssClass="fa fa-undo" url="/ui/autorollback"/>
|
||||
</System>
|
||||
</menu>
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
{#
|
||||
OPNsense Auto Rollback - Settings & Safe Mode Control Page
|
||||
|
||||
This page has two sections:
|
||||
1. Safe Mode control panel (top) - Start/Confirm/Cancel with live countdown
|
||||
2. Settings form (bottom) - Plugin configuration
|
||||
#}
|
||||
|
||||
<style>
|
||||
/* Safe Mode Control Panel */
|
||||
.safe-mode-panel {
|
||||
border-radius: 6px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.safe-mode-idle {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.safe-mode-active {
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
|
||||
border: 2px solid #f0ad4e;
|
||||
box-shadow: 0 2px 12px rgba(240, 173, 78, 0.25);
|
||||
}
|
||||
.safe-mode-restoring {
|
||||
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
|
||||
border: 2px solid #d9534f;
|
||||
animation: pulse-border 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { box-shadow: 0 2px 12px rgba(217, 83, 79, 0.2); }
|
||||
50% { box-shadow: 0 2px 20px rgba(217, 83, 79, 0.5); }
|
||||
}
|
||||
|
||||
.safe-mode-panel h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.safe-mode-panel .subtitle {
|
||||
color: #6c757d;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Countdown Display */
|
||||
.countdown-display {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -1px;
|
||||
line-height: 1;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.countdown-display .unit {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #6c757d;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.countdown-bar {
|
||||
height: 6px;
|
||||
background: #e9ecef;
|
||||
border-radius: 3px;
|
||||
margin: 12px 0 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.countdown-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 1s linear, background-color 0.5s ease;
|
||||
}
|
||||
.countdown-bar-fill.safe { background: #5cb85c; }
|
||||
.countdown-bar-fill.warning { background: #f0ad4e; }
|
||||
.countdown-bar-fill.danger { background: #d9534f; }
|
||||
|
||||
/* Action buttons */
|
||||
.safe-mode-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.safe-mode-actions .btn {
|
||||
min-width: 130px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.status-badge.idle { background: #e9ecef; color: #495057; }
|
||||
.status-badge.armed { background: #d4edda; color: #155724; }
|
||||
.status-badge.active { background: #fff3cd; color: #856404; }
|
||||
.status-badge.restoring { background: #f8d7da; color: #721c24; }
|
||||
.status-badge .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-badge.idle .dot { background: #6c757d; }
|
||||
.status-badge.armed .dot { background: #28a745; }
|
||||
.status-badge.active .dot { background: #f0ad4e; animation: blink 1s infinite; }
|
||||
.status-badge.restoring .dot { background: #d9534f; animation: blink 0.5s infinite; }
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* CLI hint */
|
||||
.cli-hint {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
color: #a8dba8;
|
||||
border-radius: 4px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cli-hint code {
|
||||
color: #e8e8e8;
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Auto Rollback - Safe Mode Controller
|
||||
$(document).ready(function() {
|
||||
let pollInterval = null;
|
||||
let countdownInterval = null;
|
||||
let currentState = {};
|
||||
|
||||
// --- API Calls ---
|
||||
function apiCall(action, data, callback) {
|
||||
$.ajax({
|
||||
url: '/api/autorollback/service/' + action,
|
||||
type: (action === 'status') ? 'GET' : 'POST',
|
||||
dataType: 'json',
|
||||
data: data || {},
|
||||
success: function(result) {
|
||||
if (callback) callback(result);
|
||||
},
|
||||
error: function(xhr) {
|
||||
console.error('Auto-rollback API error:', action, xhr);
|
||||
if (callback) callback({status: 'error', message: 'API request failed'});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- UI Update ---
|
||||
function updateUI(status) {
|
||||
currentState = status;
|
||||
let state = status.system_state || 'disabled';
|
||||
let safeMode = status.safe_mode || {};
|
||||
let panel = $('#safe-mode-panel');
|
||||
|
||||
// Update CSS class
|
||||
panel.removeClass('safe-mode-idle safe-mode-active safe-mode-restoring');
|
||||
|
||||
if (state === 'safe_mode') {
|
||||
panel.addClass('safe-mode-active');
|
||||
$('#sm-status-badge').attr('class', 'status-badge active')
|
||||
.html('<span class="dot"></span> Safe Mode Active');
|
||||
$('#sm-idle-content').hide();
|
||||
$('#sm-active-content').show();
|
||||
$('#sm-restoring-content').hide();
|
||||
|
||||
// Update countdown
|
||||
updateCountdown(safeMode.remaining_seconds || 0, safeMode.timeout || 120);
|
||||
|
||||
// CLI hint
|
||||
$('#sm-cli-hint').html(
|
||||
'<i class="fa fa-terminal"></i> SSH: <code>configctl autorollback safemode.confirm</code> to confirm | ' +
|
||||
'<code>configctl autorollback safemode.cancel</code> to revert'
|
||||
);
|
||||
|
||||
} else if (state === 'restoring') {
|
||||
panel.addClass('safe-mode-restoring');
|
||||
$('#sm-status-badge').attr('class', 'status-badge restoring')
|
||||
.html('<span class="dot"></span> Restoring');
|
||||
$('#sm-idle-content').hide();
|
||||
$('#sm-active-content').hide();
|
||||
$('#sm-restoring-content').show();
|
||||
|
||||
} else {
|
||||
panel.addClass('safe-mode-idle');
|
||||
let badgeClass = (state === 'armed') ? 'armed' : 'idle';
|
||||
let badgeText = (state === 'armed') ? 'Armed' : 'Disabled';
|
||||
$('#sm-status-badge').attr('class', 'status-badge ' + badgeClass)
|
||||
.html('<span class="dot"></span> ' + badgeText);
|
||||
$('#sm-idle-content').show();
|
||||
$('#sm-active-content').hide();
|
||||
$('#sm-restoring-content').hide();
|
||||
|
||||
// Enable/disable start button based on plugin enabled state
|
||||
$('#btn-start-safemode').prop('disabled', state === 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function updateCountdown(remaining, total) {
|
||||
if (remaining <= 0) remaining = 0;
|
||||
let minutes = Math.floor(remaining / 60);
|
||||
let seconds = remaining % 60;
|
||||
|
||||
let display = '';
|
||||
if (minutes > 0) {
|
||||
display = minutes + '<span class="unit">m</span> ' +
|
||||
String(seconds).padStart(2, '0') + '<span class="unit">s</span>';
|
||||
} else {
|
||||
display = seconds + '<span class="unit">s</span>';
|
||||
}
|
||||
$('#sm-countdown').html(display);
|
||||
|
||||
// Progress bar
|
||||
let pct = total > 0 ? (remaining / total) * 100 : 0;
|
||||
let barClass = pct > 50 ? 'safe' : (pct > 20 ? 'warning' : 'danger');
|
||||
$('#sm-countdown-bar-fill')
|
||||
.css('width', pct + '%')
|
||||
.attr('class', 'countdown-bar-fill ' + barClass);
|
||||
}
|
||||
|
||||
// --- Polling ---
|
||||
function startPolling(interval) {
|
||||
stopPolling();
|
||||
pollInterval = setInterval(function() {
|
||||
apiCall('status', null, updateUI);
|
||||
}, interval || 3000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Button Handlers ---
|
||||
$('#btn-start-safemode').on('click', function() {
|
||||
let btn = $(this);
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Starting...');
|
||||
|
||||
apiCall('start', {}, function(result) {
|
||||
if (result.status === 'ok') {
|
||||
startPolling(1000); // Fast polling during safe mode
|
||||
} else {
|
||||
alert('Failed to start safe mode: ' + (result.message || 'Unknown error'));
|
||||
btn.prop('disabled', false).html('<i class="fa fa-shield"></i> Enter Safe Mode');
|
||||
}
|
||||
// Status poll will update UI
|
||||
apiCall('status', null, updateUI);
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-confirm').on('click', function() {
|
||||
let btn = $(this);
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Confirming...');
|
||||
|
||||
apiCall('confirm', {}, function(result) {
|
||||
startPolling(3000); // Back to normal polling
|
||||
apiCall('status', null, updateUI);
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-revert').on('click', function() {
|
||||
if (!confirm('Are you sure you want to revert to the previous configuration?\n\n' +
|
||||
'Rollback method: ' + (currentState.safe_mode?.rollback_method || 'reboot') + '\n' +
|
||||
'The system may reboot.')) {
|
||||
return;
|
||||
}
|
||||
let btn = $(this);
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Reverting...');
|
||||
|
||||
apiCall('cancel', {}, function(result) {
|
||||
apiCall('status', null, updateUI);
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-extend').on('click', function() {
|
||||
apiCall('extend', {seconds: 60}, function(result) {
|
||||
apiCall('status', null, updateUI);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Settings Form ---
|
||||
// Map settings API using standard OPNsense pattern
|
||||
mapDataToFormUI({'frm_GeneralSettings': '/api/autorollback/settings/get'}).done(function() {
|
||||
formatTokenizersUI();
|
||||
$('.selectpicker').selectpicker('refresh');
|
||||
});
|
||||
|
||||
$('#btn-save-settings').on('click', function() {
|
||||
saveFormToEndpoint('/api/autorollback/settings/set', 'frm_GeneralSettings', function() {
|
||||
// Refresh cron after settings change
|
||||
ajaxCall('/api/autorollback/service/status', {}, function(data) {
|
||||
updateUI(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Initial load ---
|
||||
apiCall('status', null, function(status) {
|
||||
updateUI(status);
|
||||
if (status.system_state === 'safe_mode') {
|
||||
startPolling(1000);
|
||||
} else {
|
||||
startPolling(5000);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Safe Mode Control Panel -->
|
||||
<div id="safe-mode-panel" class="safe-mode-panel safe-mode-idle">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<h3><i class="fa fa-undo"></i> Safe Mode</h3>
|
||||
<div class="subtitle">Make configuration changes safely with automatic rollback protection</div>
|
||||
</div>
|
||||
<div id="sm-status-badge" class="status-badge idle">
|
||||
<span class="dot"></span> Disabled
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Idle state -->
|
||||
<div id="sm-idle-content">
|
||||
<p style="margin: 8px 0 16px; color: #495057;">
|
||||
Enter safe mode to snapshot your current configuration before making changes.
|
||||
If you don't confirm within the timeout, the system will automatically revert.
|
||||
</p>
|
||||
<div class="safe-mode-actions">
|
||||
<button id="btn-start-safemode" class="btn btn-primary" disabled>
|
||||
<i class="fa fa-shield"></i> Enter Safe Mode
|
||||
</button>
|
||||
</div>
|
||||
<div class="cli-hint" style="margin-top: 16px;">
|
||||
<i class="fa fa-terminal"></i> SSH: <code>configctl autorollback safemode.start</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active state (countdown) -->
|
||||
<div id="sm-active-content" style="display:none;">
|
||||
<div class="countdown-display" id="sm-countdown">
|
||||
120<span class="unit">s</span>
|
||||
</div>
|
||||
<div class="countdown-bar">
|
||||
<div id="sm-countdown-bar-fill" class="countdown-bar-fill safe" style="width: 100%;"></div>
|
||||
</div>
|
||||
<div class="safe-mode-actions">
|
||||
<button id="btn-confirm" class="btn btn-success btn-lg">
|
||||
<i class="fa fa-check"></i> Confirm Changes
|
||||
</button>
|
||||
<button id="btn-revert" class="btn btn-danger">
|
||||
<i class="fa fa-undo"></i> Revert Now
|
||||
</button>
|
||||
<button id="btn-extend" class="btn btn-default">
|
||||
<i class="fa fa-clock-o"></i> +60s
|
||||
</button>
|
||||
</div>
|
||||
<div id="sm-cli-hint" class="cli-hint" style="margin-top: 12px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Restoring state -->
|
||||
<div id="sm-restoring-content" style="display:none;">
|
||||
<p style="color: #721c24; font-weight: 600; font-size: 16px;">
|
||||
<i class="fa fa-spinner fa-spin"></i> Rollback in progress...
|
||||
</p>
|
||||
<p style="color: #721c24;">
|
||||
The system is reverting to the previous configuration. This page may become temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs">
|
||||
<li class="active"><a data-toggle="tab" href="#settings">{{ lang._('Settings') }}</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content content-box">
|
||||
<div id="settings" class="tab-pane fade in active">
|
||||
{{ partial("layout_partials/base_form", ['fields': generalForm, 'id': 'frm_GeneralSettings']) }}
|
||||
|
||||
<div class="col-md-12">
|
||||
<hr/>
|
||||
<button class="btn btn-primary" id="btn-save-settings" type="button">
|
||||
<b>{{ lang._('Save') }}</b> <i id="btn-save-settings_progress" class=""></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Shared library
|
||||
"""
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Common library
|
||||
Shared constants, state management, and utility functions.
|
||||
|
||||
State architecture:
|
||||
- Volatile state (cleared on reboot): /var/run/autorollback/
|
||||
* timer PID, active session flag, confirmation token
|
||||
- Persistent state (survives reboot): /conf/autorollback_pending.json
|
||||
* known-good backup path, expiry timestamp (for early-boot recovery)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import fcntl
|
||||
import glob
|
||||
import ipaddress
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import syslog
|
||||
import secrets
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# --- Path constants ---
|
||||
VOLATILE_DIR = '/var/run/autorollback'
|
||||
PERSISTENT_STATE_FILE = '/conf/autorollback_pending.json'
|
||||
TIMER_PID_FILE = os.path.join(VOLATILE_DIR, 'timer.pid')
|
||||
RESTORE_LOCK_FILE = os.path.join(VOLATILE_DIR, 'restoring.lock')
|
||||
SESSION_TOKEN_FILE = os.path.join(VOLATILE_DIR, 'session.token')
|
||||
WATCHDOG_FAIL_COUNT_FILE = os.path.join(VOLATILE_DIR, 'watchdog_failures')
|
||||
WATCHDOG_LAST_CONFIG_FILE = os.path.join(VOLATILE_DIR, 'last_config_change')
|
||||
|
||||
CONFIG_XML = '/conf/config.xml'
|
||||
CONFIG_BACKUP_DIR = '/conf/backup'
|
||||
CONFIG_CACHE = '/tmp/config.cache'
|
||||
|
||||
# Firmware update indicators
|
||||
FIRMWARE_LOCK = '/tmp/pkg_upgrade.progress'
|
||||
FIRMWARE_PROCS = ['opnsense-update', 'opnsense-bootstrap', 'opnsense-patch']
|
||||
|
||||
# Regex for valid timestamped backup filenames
|
||||
BACKUP_TIMESTAMP_RE = re.compile(r'^config-\d+(\.\d+)?(_\d+)?\.xml$')
|
||||
|
||||
|
||||
# --- Syslog setup (open once at module load, never close) ---
|
||||
syslog.openlog('autorollback', syslog.LOG_PID, syslog.LOG_LOCAL4)
|
||||
|
||||
def log_info(msg):
|
||||
syslog.syslog(syslog.LOG_INFO, msg)
|
||||
|
||||
def log_warning(msg):
|
||||
syslog.syslog(syslog.LOG_WARNING, msg)
|
||||
|
||||
def log_error(msg):
|
||||
syslog.syslog(syslog.LOG_ERR, msg)
|
||||
|
||||
|
||||
# --- Directory management ---
|
||||
def ensure_volatile_dir():
|
||||
"""Create the volatile state directory if it doesn't exist."""
|
||||
os.makedirs(VOLATILE_DIR, mode=0o750, exist_ok=True)
|
||||
|
||||
|
||||
# --- Settings reader (single source of truth) ---
|
||||
def read_model_settings():
|
||||
"""Read all plugin settings from config.xml. Used by all scripts."""
|
||||
defaults = {
|
||||
'enabled': False,
|
||||
'timeout': 120,
|
||||
'rollback_method': 'reboot',
|
||||
'watchdog_enabled': False,
|
||||
'grace_period': 60,
|
||||
'fail_threshold': 3,
|
||||
'check_command': 'ping -c 1 -W 3 -t 5 %gateway%',
|
||||
'check_pattern': '1 packets received',
|
||||
'check_command_2': '',
|
||||
'check_pattern_2': '',
|
||||
'log_rollbacks': True,
|
||||
}
|
||||
try:
|
||||
tree = ET.parse(CONFIG_XML)
|
||||
root = tree.getroot()
|
||||
ar = root.find('.//OPNsense/autorollback/general')
|
||||
if ar is not None:
|
||||
return {
|
||||
'enabled': (ar.findtext('Enabled', '0') == '1'),
|
||||
'timeout': int(ar.findtext('SafeModeTimeout', '120')),
|
||||
'rollback_method': ar.findtext('RollbackMethod', 'reboot'),
|
||||
'watchdog_enabled': (ar.findtext('WatchdogEnabled', '0') == '1'),
|
||||
'grace_period': int(ar.findtext('WatchdogGracePeriod', '60')),
|
||||
'fail_threshold': int(ar.findtext('WatchdogFailThreshold', '3')),
|
||||
'check_command': ar.findtext('WatchdogCheckCommand',
|
||||
'ping -c 1 -W 3 -t 5 %gateway%'),
|
||||
'check_pattern': ar.findtext('WatchdogCheckPattern',
|
||||
'1 packets received'),
|
||||
'check_command_2': ar.findtext('WatchdogCheckCommand2', ''),
|
||||
'check_pattern_2': ar.findtext('WatchdogCheckPattern2', ''),
|
||||
'log_rollbacks': (ar.findtext('LogRollbacks', '1') == '1'),
|
||||
}
|
||||
except Exception as e:
|
||||
log_warning('Could not read model settings: %s' % str(e))
|
||||
return defaults
|
||||
|
||||
|
||||
# --- Persistent state management ---
|
||||
def read_persistent_state():
|
||||
"""Read the persistent state file. Returns dict or None."""
|
||||
try:
|
||||
if os.path.isfile(PERSISTENT_STATE_FILE):
|
||||
with open(PERSISTENT_STATE_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError, OSError) as e:
|
||||
log_warning('Failed to read persistent state: %s' % str(e))
|
||||
return None
|
||||
|
||||
def write_persistent_state(state):
|
||||
"""Write persistent state atomically using temp file + rename."""
|
||||
dir_name = os.path.dirname(PERSISTENT_STATE_FILE)
|
||||
fd_num = None
|
||||
tmp_path = None
|
||||
try:
|
||||
fd_num, tmp_path = tempfile.mkstemp(dir=dir_name, prefix='.autorollback_')
|
||||
with os.fdopen(fd_num, 'w') as f:
|
||||
fd_num = None # os.fdopen takes ownership
|
||||
json.dump(state, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.rename(tmp_path, PERSISTENT_STATE_FILE)
|
||||
tmp_path = None # Rename succeeded
|
||||
except (IOError, OSError) as e:
|
||||
log_error('Failed to write persistent state: %s' % str(e))
|
||||
if tmp_path and os.path.isfile(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
finally:
|
||||
if fd_num is not None:
|
||||
os.close(fd_num)
|
||||
|
||||
def clear_persistent_state():
|
||||
"""Remove the persistent state file."""
|
||||
try:
|
||||
if os.path.isfile(PERSISTENT_STATE_FILE):
|
||||
os.unlink(PERSISTENT_STATE_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# --- Session token management ---
|
||||
def generate_session_token():
|
||||
"""Generate a cryptographically random session token for safe mode."""
|
||||
token = secrets.token_hex(32)
|
||||
ensure_volatile_dir()
|
||||
fd = os.open(SESSION_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, token.encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
return token
|
||||
|
||||
def read_session_token():
|
||||
"""Read the current session token, or None."""
|
||||
try:
|
||||
if os.path.isfile(SESSION_TOKEN_FILE):
|
||||
with open(SESSION_TOKEN_FILE, 'r') as f:
|
||||
return f.read().strip()
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def clear_session_token():
|
||||
"""Remove the session token file."""
|
||||
try:
|
||||
if os.path.isfile(SESSION_TOKEN_FILE):
|
||||
os.unlink(SESSION_TOKEN_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# --- Re-entrancy guard ---
|
||||
def is_restore_in_progress():
|
||||
"""Check if a restore operation is currently running (re-entrancy guard)."""
|
||||
if not os.path.isfile(RESTORE_LOCK_FILE):
|
||||
return False
|
||||
fd = None
|
||||
try:
|
||||
fd = open(RESTORE_LOCK_FILE, 'r')
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# We got the lock — nobody holds it, stale file
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
try:
|
||||
os.unlink(RESTORE_LOCK_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
except (BlockingIOError, OSError):
|
||||
return True # Lock held — restore in progress
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
finally:
|
||||
if fd is not None:
|
||||
fd.close()
|
||||
|
||||
def acquire_restore_lock():
|
||||
"""Acquire the restore lock. Returns file descriptor or None."""
|
||||
ensure_volatile_dir()
|
||||
try:
|
||||
fd = open(RESTORE_LOCK_FILE, 'w')
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fd.write(str(os.getpid()))
|
||||
fd.flush()
|
||||
return fd
|
||||
except (BlockingIOError, IOError, OSError):
|
||||
return None
|
||||
|
||||
def release_restore_lock(fd):
|
||||
"""Release the restore lock."""
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
fd.close()
|
||||
if os.path.isfile(RESTORE_LOCK_FILE):
|
||||
os.unlink(RESTORE_LOCK_FILE)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
# --- Timer PID management ---
|
||||
def read_timer_pid():
|
||||
"""Read the PID of the running background timer, or None."""
|
||||
try:
|
||||
if os.path.isfile(TIMER_PID_FILE):
|
||||
with open(TIMER_PID_FILE, 'r') as f:
|
||||
pid = int(f.read().strip())
|
||||
# Check if process is still alive
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (ValueError, ProcessLookupError, PermissionError, IOError, OSError):
|
||||
clean_timer_pid()
|
||||
return None
|
||||
|
||||
def write_timer_pid(pid):
|
||||
"""Store the timer process PID."""
|
||||
ensure_volatile_dir()
|
||||
with open(TIMER_PID_FILE, 'w') as f:
|
||||
f.write(str(pid))
|
||||
|
||||
def clean_timer_pid():
|
||||
"""Remove the timer PID file."""
|
||||
try:
|
||||
if os.path.isfile(TIMER_PID_FILE):
|
||||
os.unlink(TIMER_PID_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# --- Kill running timer ---
|
||||
def kill_timer():
|
||||
"""Kill the background timer process if running."""
|
||||
pid = read_timer_pid()
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _ in range(10):
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
clean_timer_pid()
|
||||
|
||||
|
||||
# --- Safe mode state queries ---
|
||||
def is_safe_mode_active():
|
||||
"""Check if safe mode is currently active."""
|
||||
state = read_persistent_state()
|
||||
if state is None:
|
||||
return False
|
||||
if state.get('mode') != 'safemode':
|
||||
return False
|
||||
if read_timer_pid() is not None:
|
||||
return True
|
||||
expiry = state.get('expiry_time', 0)
|
||||
if time.time() < expiry:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_safe_mode_info():
|
||||
"""Get full safe mode status information. Always returns all keys."""
|
||||
state = read_persistent_state()
|
||||
default = {
|
||||
'active': False,
|
||||
'mode': 'idle',
|
||||
'backup_file': '',
|
||||
'backup_revision': '',
|
||||
'start_time': 0,
|
||||
'expiry_time': 0,
|
||||
'remaining_seconds': 0,
|
||||
'timeout': 0,
|
||||
'rollback_method': 'reboot',
|
||||
'timer_pid': None,
|
||||
'token': None,
|
||||
}
|
||||
if state is None:
|
||||
return default
|
||||
|
||||
now = time.time()
|
||||
expiry = state.get('expiry_time', 0)
|
||||
remaining = max(0, expiry - now)
|
||||
|
||||
return {
|
||||
'active': state.get('mode') == 'safemode' and (
|
||||
remaining > 0 or read_timer_pid() is not None),
|
||||
'mode': state.get('mode', 'idle'),
|
||||
'backup_file': state.get('backup_file', ''),
|
||||
'backup_revision': state.get('backup_revision', ''),
|
||||
'start_time': state.get('start_time', 0),
|
||||
'expiry_time': expiry,
|
||||
'remaining_seconds': int(remaining),
|
||||
'timeout': state.get('timeout', 0),
|
||||
'rollback_method': state.get('rollback_method', 'reboot'),
|
||||
'timer_pid': read_timer_pid(),
|
||||
'token': read_session_token(),
|
||||
}
|
||||
|
||||
|
||||
# --- Firmware update detection ---
|
||||
def is_firmware_update_running():
|
||||
"""Check if a firmware update is in progress."""
|
||||
if os.path.isfile(FIRMWARE_LOCK):
|
||||
return True
|
||||
try:
|
||||
for proc_name in FIRMWARE_PROCS:
|
||||
result = subprocess.run(
|
||||
['pgrep', '-x', proc_name], # -x = exact match on process name
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# --- Config backup helpers ---
|
||||
def get_latest_backup():
|
||||
"""Get the path of the most recent timestamped config backup."""
|
||||
backups = glob.glob(os.path.join(CONFIG_BACKUP_DIR, 'config-*.xml'))
|
||||
# Only consider timestamped backups, not safety backups like config-pre-rollback.xml
|
||||
backups = [b for b in backups if BACKUP_TIMESTAMP_RE.match(os.path.basename(b))]
|
||||
backups.sort()
|
||||
if backups:
|
||||
return backups[-1]
|
||||
return None
|
||||
|
||||
def get_previous_backup():
|
||||
"""Get the second-most-recent timestamped backup (the one BEFORE the latest)."""
|
||||
backups = glob.glob(os.path.join(CONFIG_BACKUP_DIR, 'config-*.xml'))
|
||||
backups = [b for b in backups if BACKUP_TIMESTAMP_RE.match(os.path.basename(b))]
|
||||
backups.sort()
|
||||
if len(backups) >= 2:
|
||||
return backups[-2]
|
||||
return None
|
||||
|
||||
def get_backup_revision(backup_path):
|
||||
"""Extract the revision timestamp from a backup filename."""
|
||||
basename = os.path.basename(backup_path)
|
||||
if basename.startswith('config-') and basename.endswith('.xml'):
|
||||
return basename[7:-4]
|
||||
return None
|
||||
|
||||
|
||||
# --- Gateway detection ---
|
||||
def get_default_gateway():
|
||||
"""Get the default gateway IP from the routing table. Returns validated IP string."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['route', '-n', 'get', 'default'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('gateway:'):
|
||||
gw = line.split(':', 1)[1].strip()
|
||||
# Validate it's a real IP address (prevents injection)
|
||||
ipaddress.ip_address(gw)
|
||||
return gw
|
||||
except (subprocess.TimeoutExpired, OSError, ValueError, IndexError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# --- Configd helper ---
|
||||
def configctl(cmd, timeout=60):
|
||||
"""Run a configctl command. Uses shlex for safe argument splitting."""
|
||||
try:
|
||||
if os.path.exists('/var/run/configd.socket'):
|
||||
result = subprocess.run(
|
||||
['configctl'] + shlex.split(cmd),
|
||||
capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
return result.returncode == 0, result.stdout.strip()
|
||||
else:
|
||||
log_warning('configd socket not available, skipping configctl: %s' % cmd)
|
||||
return False, 'configd unavailable'
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
log_warning('configctl failed for "%s": %s' % (cmd, str(e)))
|
||||
return False, str(e)
|
||||
377
sysutils/autorollback/src/opnsense/scripts/autorollback/rollback.py
Executable file
377
sysutils/autorollback/src/opnsense/scripts/autorollback/rollback.py
Executable file
|
|
@ -0,0 +1,377 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Rollback Executor
|
||||
|
||||
This script performs the actual configuration rollback. It is called by:
|
||||
1. timer_daemon.py (on timer expiry)
|
||||
2. safemode.py cancel (manual cancel)
|
||||
3. watchdog.py (on connectivity failure)
|
||||
4. 10-autorollback-recovery (early boot recovery)
|
||||
|
||||
Safety features:
|
||||
- Acquires exclusive restore lock (prevents re-entrancy)
|
||||
- Validates backup file path (must be within /conf/)
|
||||
- Validates backup file content before restore
|
||||
- Creates safety backup before overwriting config
|
||||
- Atomic restore via temp file + rename
|
||||
- Preserves original config.xml ownership
|
||||
- Removes config cache to force fresh read
|
||||
- Supports two rollback methods: full reboot or service reload
|
||||
- Falls back to direct script execution if configd is unavailable
|
||||
- Logs everything to syslog
|
||||
|
||||
Usage: rollback.py <backup_file_path> <rollback_method>
|
||||
rollback_method: "reboot" or "reload"
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from lib.common import (
|
||||
log_info, log_warning, log_error,
|
||||
acquire_restore_lock, release_restore_lock,
|
||||
is_firmware_update_running,
|
||||
CONFIG_XML, CONFIG_CACHE, CONFIG_BACKUP_DIR
|
||||
)
|
||||
|
||||
# Allowed directories for backup files (path traversal defense)
|
||||
ALLOWED_BACKUP_DIRS = (
|
||||
os.path.realpath(CONFIG_BACKUP_DIR),
|
||||
os.path.realpath('/conf'),
|
||||
)
|
||||
|
||||
|
||||
def validate_backup_path(path):
|
||||
"""
|
||||
Validate that a backup file path is within allowed directories.
|
||||
Prevents path traversal attacks.
|
||||
"""
|
||||
real_path = os.path.realpath(path)
|
||||
for allowed_dir in ALLOWED_BACKUP_DIRS:
|
||||
if real_path.startswith(allowed_dir + os.sep) or real_path == allowed_dir:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_config_xml(path):
|
||||
"""Validate that a file is a parseable OPNsense config.xml."""
|
||||
try:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
# Basic sanity: must have <opnsense> or legacy <pfsense> root
|
||||
if root.tag not in ('opnsense', 'pfsense'):
|
||||
return False, 'Root element is "%s", expected "opnsense"' % root.tag
|
||||
# Must have a system section
|
||||
if root.find('system') is None:
|
||||
return False, 'Missing <system> section'
|
||||
# Must have interfaces
|
||||
if root.find('interfaces') is None:
|
||||
return False, 'Missing <interfaces> section'
|
||||
return True, 'Valid'
|
||||
except ET.ParseError as e:
|
||||
return False, 'XML parse error: %s' % str(e)
|
||||
except Exception as e:
|
||||
return False, 'Validation error: %s' % str(e)
|
||||
|
||||
|
||||
def _get_file_ownership(path):
|
||||
"""Get the uid/gid of an existing file. Returns (uid, gid) or None."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
return st.st_uid, st.st_gid
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def restore_config(backup_path):
|
||||
"""
|
||||
Restore a config.xml backup file.
|
||||
|
||||
Strategy:
|
||||
1. Validate the backup path and content
|
||||
2. Create a safety backup of the CURRENT config (in case rollback makes things worse)
|
||||
3. Preserve original file ownership
|
||||
4. Copy backup to /conf/config.xml atomically via temp file + rename
|
||||
5. Remove config cache
|
||||
"""
|
||||
# Validate path is within allowed directories
|
||||
if not validate_backup_path(backup_path):
|
||||
msg = 'Backup path outside allowed directories: %s' % backup_path
|
||||
log_error(msg)
|
||||
return False, msg
|
||||
|
||||
# Validate backup content
|
||||
valid, msg = validate_config_xml(backup_path)
|
||||
if not valid:
|
||||
log_error('Backup validation failed for %s: %s' % (backup_path, msg))
|
||||
return False, msg
|
||||
|
||||
# Capture existing ownership before we overwrite
|
||||
ownership = _get_file_ownership(CONFIG_XML)
|
||||
|
||||
# Safety backup of current config (last resort recovery)
|
||||
safety_backup = os.path.join(CONFIG_BACKUP_DIR, 'config-pre-rollback.xml')
|
||||
try:
|
||||
if os.path.isfile(CONFIG_XML):
|
||||
shutil.copy2(CONFIG_XML, safety_backup)
|
||||
log_info('Safety backup created: %s' % safety_backup)
|
||||
except Exception as e:
|
||||
log_warning('Could not create safety backup: %s' % str(e))
|
||||
# Continue anyway — the rollback is more important
|
||||
|
||||
# Restore the config atomically via temp file + rename
|
||||
tmp_fd = None
|
||||
tmp_path = None
|
||||
try:
|
||||
conf_dir = os.path.dirname(CONFIG_XML)
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(dir=conf_dir, prefix='.config_rollback_')
|
||||
|
||||
# Close the fd from mkstemp, copy file content
|
||||
os.close(tmp_fd)
|
||||
tmp_fd = None
|
||||
|
||||
shutil.copy2(backup_path, tmp_path)
|
||||
|
||||
# Set permissions — OPNsense expects 0640
|
||||
os.chmod(tmp_path, 0o640)
|
||||
|
||||
# Preserve original ownership if we captured it, otherwise use root:wheel
|
||||
if ownership:
|
||||
uid, gid = ownership
|
||||
else:
|
||||
try:
|
||||
import pwd
|
||||
import grp
|
||||
uid = pwd.getpwnam('root').pw_uid
|
||||
gid = grp.getgrnam('wheel').gr_gid
|
||||
except (KeyError, ImportError):
|
||||
uid, gid = 0, 0
|
||||
|
||||
try:
|
||||
os.chown(tmp_path, uid, gid)
|
||||
except PermissionError:
|
||||
pass # Best effort
|
||||
|
||||
os.rename(tmp_path, CONFIG_XML)
|
||||
tmp_path = None # Rename succeeded, don't clean up
|
||||
log_info('Configuration restored from: %s' % backup_path)
|
||||
except Exception as e:
|
||||
log_error('Failed to restore config: %s' % str(e))
|
||||
# Clean up failed temp file
|
||||
if tmp_path and os.path.isfile(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
# Try to restore from safety backup
|
||||
if os.path.isfile(safety_backup):
|
||||
try:
|
||||
shutil.copy2(safety_backup, CONFIG_XML)
|
||||
log_info('Restored from safety backup after failed rollback')
|
||||
except Exception:
|
||||
pass
|
||||
return False, 'Failed to restore: %s' % str(e)
|
||||
finally:
|
||||
if tmp_fd is not None:
|
||||
os.close(tmp_fd)
|
||||
|
||||
# Remove config cache so PHP reads fresh config
|
||||
try:
|
||||
if os.path.isfile(CONFIG_CACHE):
|
||||
os.unlink(CONFIG_CACHE)
|
||||
log_info('Config cache removed')
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return True, 'Configuration restored successfully'
|
||||
|
||||
|
||||
def apply_reboot():
|
||||
"""Apply configuration by rebooting the system."""
|
||||
log_info('ROLLBACK: Initiating full system reboot')
|
||||
try:
|
||||
# Try configd first
|
||||
if os.path.exists('/var/run/configd.socket'):
|
||||
subprocess.run(
|
||||
['configctl', 'system', 'reboot'],
|
||||
capture_output=True, timeout=10
|
||||
)
|
||||
else:
|
||||
# Direct reboot
|
||||
subprocess.Popen(
|
||||
['/usr/local/etc/rc.reboot'],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_error('Reboot command failed: %s' % str(e))
|
||||
# Last resort
|
||||
try:
|
||||
subprocess.Popen(
|
||||
['shutdown', '-r', 'now'],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
return True
|
||||
except Exception as e2:
|
||||
log_error('All reboot methods failed: %s' % str(e2))
|
||||
return False
|
||||
|
||||
|
||||
def apply_reload():
|
||||
"""Apply configuration by reloading all services (no reboot)."""
|
||||
log_info('ROLLBACK: Initiating service reload via rc.reload_all')
|
||||
try:
|
||||
# rc.reload_all accepts a delay parameter
|
||||
proc = subprocess.Popen(
|
||||
['/usr/local/etc/rc.reload_all'],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
# Don't wait for it — it can take a while and we don't want to block
|
||||
log_info('rc.reload_all started (pid=%d)' % proc.pid)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_error('rc.reload_all failed: %s' % str(e))
|
||||
# Fallback: try individual service restarts
|
||||
log_info('Attempting individual service restarts as fallback')
|
||||
try:
|
||||
if os.path.exists('/var/run/configd.socket'):
|
||||
for cmd in ['filter reload', 'interface reconfigure',
|
||||
'dns reload', 'dhcpd restart']:
|
||||
try:
|
||||
subprocess.run(
|
||||
['configctl'] + cmd.split(),
|
||||
capture_output=True, timeout=30
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as e2:
|
||||
log_error('Fallback service restarts also failed: %s' % str(e2))
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(json.dumps({
|
||||
'status': 'error',
|
||||
'message': 'Usage: rollback.py <backup_file> <rollback_method>'
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
backup_file = sys.argv[1]
|
||||
rollback_method = sys.argv[2]
|
||||
|
||||
# Validate inputs
|
||||
if not os.path.isfile(backup_file):
|
||||
msg = 'Backup file does not exist: %s' % backup_file
|
||||
log_error(msg)
|
||||
print(json.dumps({'status': 'error', 'message': msg}))
|
||||
sys.exit(1)
|
||||
|
||||
if not validate_backup_path(backup_file):
|
||||
msg = 'Backup file outside allowed directories: %s' % backup_file
|
||||
log_error(msg)
|
||||
print(json.dumps({'status': 'error', 'message': msg}))
|
||||
sys.exit(1)
|
||||
|
||||
if rollback_method not in ('reboot', 'reload'):
|
||||
rollback_method = 'reboot' # Default to safest option
|
||||
log_warning('Unknown rollback method, defaulting to reboot')
|
||||
|
||||
# Prevent rollback during firmware updates
|
||||
if is_firmware_update_running():
|
||||
msg = 'Rollback blocked: firmware update in progress'
|
||||
log_warning(msg)
|
||||
print(json.dumps({'status': 'blocked', 'message': msg}))
|
||||
sys.exit(1)
|
||||
|
||||
# Acquire exclusive lock
|
||||
lock_fd = acquire_restore_lock()
|
||||
if lock_fd is None:
|
||||
msg = 'Another rollback is already in progress'
|
||||
log_warning(msg)
|
||||
print(json.dumps({'status': 'locked', 'message': msg}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Step 1: Restore config.xml
|
||||
log_info('=== ROLLBACK STARTING === backup=%s method=%s' % (
|
||||
backup_file, rollback_method))
|
||||
|
||||
success, msg = restore_config(backup_file)
|
||||
if not success:
|
||||
print(json.dumps({'status': 'error', 'message': msg}))
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Apply the restored config
|
||||
if rollback_method == 'reboot':
|
||||
apply_success = apply_reboot()
|
||||
else:
|
||||
apply_success = apply_reload()
|
||||
|
||||
if apply_success:
|
||||
log_info('=== ROLLBACK COMPLETE === method=%s' % rollback_method)
|
||||
print(json.dumps({
|
||||
'status': 'ok',
|
||||
'message': 'Rollback completed (method: %s)' % rollback_method,
|
||||
'backup_restored': backup_file,
|
||||
'method': rollback_method,
|
||||
}))
|
||||
else:
|
||||
log_error('=== ROLLBACK APPLY FAILED === method=%s' % rollback_method)
|
||||
# If reload failed, try reboot as last resort
|
||||
if rollback_method == 'reload':
|
||||
log_info('Reload failed, falling back to reboot')
|
||||
apply_reboot()
|
||||
print(json.dumps({
|
||||
'status': 'partial',
|
||||
'message': 'Config restored but service apply failed. Rebooting.',
|
||||
'backup_restored': backup_file,
|
||||
}))
|
||||
|
||||
finally:
|
||||
release_restore_lock(lock_fd)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
368
sysutils/autorollback/src/opnsense/scripts/autorollback/safemode.py
Executable file
368
sysutils/autorollback/src/opnsense/scripts/autorollback/safemode.py
Executable file
|
|
@ -0,0 +1,368 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Safe Mode Controller
|
||||
|
||||
Usage:
|
||||
safemode.py start [timeout_seconds]
|
||||
safemode.py confirm
|
||||
safemode.py cancel
|
||||
safemode.py extend [additional_seconds]
|
||||
|
||||
Start: Snapshots current config, launches background timer.
|
||||
Confirm: Accepts changes, kills timer, clears state.
|
||||
Cancel: Manually triggers rollback immediately.
|
||||
Extend: Adds time to the countdown.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Add parent directory to path for lib imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from lib.common import (
|
||||
ensure_volatile_dir, log_info, log_warning, log_error,
|
||||
read_model_settings,
|
||||
read_persistent_state, write_persistent_state, clear_persistent_state,
|
||||
generate_session_token, clear_session_token,
|
||||
is_safe_mode_active, get_safe_mode_info,
|
||||
is_firmware_update_running, is_restore_in_progress,
|
||||
get_latest_backup, get_backup_revision,
|
||||
write_timer_pid, kill_timer, read_timer_pid,
|
||||
VOLATILE_DIR, CONFIG_XML, CONFIG_BACKUP_DIR
|
||||
)
|
||||
|
||||
|
||||
def force_config_save():
|
||||
"""
|
||||
Force OPNsense to save the current config, creating a backup.
|
||||
We do this to ensure we have a backup of the exact running state.
|
||||
Returns the backup path or None.
|
||||
"""
|
||||
try:
|
||||
# Use configctl to trigger a config save
|
||||
result = subprocess.run(
|
||||
['configctl', 'firmware', 'configure'],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
# Now find the most recent backup
|
||||
backup = get_latest_backup()
|
||||
if backup:
|
||||
log_info('Config backup created: %s' % backup)
|
||||
return backup
|
||||
else:
|
||||
log_error('No backup found after config save')
|
||||
return None
|
||||
except Exception as e:
|
||||
log_error('Failed to force config save: %s' % str(e))
|
||||
return None
|
||||
|
||||
|
||||
def _launch_timer_daemon(timeout, rollback_method):
|
||||
"""Launch the background timer daemon process. Returns (pid, error_msg)."""
|
||||
timer_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'timer_daemon.py')
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, timer_script, str(int(timeout)), rollback_method],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True # Detach from parent
|
||||
)
|
||||
write_timer_pid(proc.pid)
|
||||
return proc.pid, None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def start_safe_mode(timeout_override=None):
|
||||
"""Enter safe mode. Snapshot config and start countdown timer."""
|
||||
result = {'status': 'error', 'message': ''}
|
||||
|
||||
# Pre-flight checks
|
||||
settings = read_model_settings()
|
||||
if not settings['enabled']:
|
||||
result['message'] = 'Auto-rollback plugin is disabled. Enable it in System > Auto Rollback.'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
if is_firmware_update_running():
|
||||
result['message'] = 'Cannot enter safe mode during a firmware update.'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
if is_restore_in_progress():
|
||||
result['message'] = 'A restore operation is already in progress.'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
if is_safe_mode_active():
|
||||
info = get_safe_mode_info()
|
||||
result['message'] = 'Safe mode is already active (%d seconds remaining).' % info['remaining_seconds']
|
||||
result['status'] = 'already_active'
|
||||
result.update(info)
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
# Determine timeout — use is not None to allow timeout_override=0 edge case
|
||||
if timeout_override is not None:
|
||||
timeout = timeout_override
|
||||
else:
|
||||
timeout = settings['timeout']
|
||||
timeout = max(30, min(3600, int(timeout)))
|
||||
|
||||
# Step 1: Get the current config as our "known good" backup
|
||||
# The most recent backup IS the current running config (saved moments ago)
|
||||
backup = get_latest_backup()
|
||||
if not backup:
|
||||
# Force a save to create one
|
||||
backup = force_config_save()
|
||||
if not backup:
|
||||
result['message'] = 'Failed to create configuration backup.'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
backup_revision = get_backup_revision(backup)
|
||||
now = time.time()
|
||||
expiry = now + timeout
|
||||
|
||||
# Step 2: Generate session token for the confirmation UI
|
||||
token = generate_session_token()
|
||||
|
||||
# Step 3: Write persistent state (survives reboot for early-boot recovery)
|
||||
state = {
|
||||
'mode': 'safemode',
|
||||
'backup_file': backup,
|
||||
'backup_revision': backup_revision,
|
||||
'start_time': now,
|
||||
'expiry_time': expiry,
|
||||
'timeout': timeout,
|
||||
'rollback_method': settings['rollback_method'],
|
||||
}
|
||||
write_persistent_state(state)
|
||||
|
||||
# Step 4: Launch background timer process
|
||||
pid, err = _launch_timer_daemon(timeout, settings['rollback_method'])
|
||||
if pid is None:
|
||||
log_error('Failed to start timer daemon: %s' % err)
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
result['message'] = 'Failed to start countdown timer: %s' % err
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
log_info('Safe mode started: timeout=%ds, backup=%s, timer_pid=%d' % (
|
||||
timeout, backup, pid))
|
||||
|
||||
# Step 5: Trigger git backup if available
|
||||
try:
|
||||
subprocess.run(
|
||||
['configctl', 'firmware', 'configure'],
|
||||
capture_output=True, timeout=10
|
||||
)
|
||||
except Exception:
|
||||
pass # Non-critical
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'message': 'Safe mode activated. You have %d seconds to confirm changes.' % timeout,
|
||||
'timeout': timeout,
|
||||
'remaining_seconds': timeout,
|
||||
'expiry_time': expiry,
|
||||
'backup_file': backup,
|
||||
'backup_revision': backup_revision,
|
||||
'token': token,
|
||||
'rollback_method': settings['rollback_method'],
|
||||
}
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
def confirm_safe_mode():
|
||||
"""Confirm changes and exit safe mode gracefully."""
|
||||
result = {'status': 'error', 'message': ''}
|
||||
|
||||
if not is_safe_mode_active():
|
||||
result['message'] = 'Safe mode is not active.'
|
||||
result['status'] = 'not_active'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
# Kill the background timer
|
||||
kill_timer()
|
||||
|
||||
# Clear all state
|
||||
state = read_persistent_state()
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
|
||||
log_info('Safe mode confirmed. Changes accepted. Previous backup: %s' % (
|
||||
state.get('backup_file', 'unknown') if state else 'unknown'))
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'message': 'Changes confirmed. Safe mode deactivated.',
|
||||
}
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
def cancel_safe_mode():
|
||||
"""Cancel changes and rollback immediately."""
|
||||
result = {'status': 'error', 'message': ''}
|
||||
|
||||
state = read_persistent_state()
|
||||
if state is None or state.get('mode') != 'safemode':
|
||||
result['message'] = 'Safe mode is not active.'
|
||||
result['status'] = 'not_active'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
# Kill the background timer first
|
||||
kill_timer()
|
||||
|
||||
backup_file = state.get('backup_file', '')
|
||||
rollback_method = state.get('rollback_method', 'reboot')
|
||||
|
||||
if not backup_file or not os.path.isfile(backup_file):
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
result['message'] = 'Backup file not found: %s' % backup_file
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
log_info('Safe mode cancelled. Rolling back to: %s (method: %s)' % (
|
||||
backup_file, rollback_method))
|
||||
|
||||
# Clear state before rollback (important: prevents re-entrancy)
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
|
||||
# Execute rollback
|
||||
rollback_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'rollback.py')
|
||||
try:
|
||||
proc_result = subprocess.run(
|
||||
[sys.executable, rollback_script, backup_file, rollback_method],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if proc_result.returncode == 0:
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'message': 'Rollback initiated (method: %s). System is reverting.' % rollback_method,
|
||||
'rollback_method': rollback_method,
|
||||
}
|
||||
else:
|
||||
result['message'] = 'Rollback script failed: %s' % proc_result.stderr
|
||||
except Exception as e:
|
||||
result['message'] = 'Rollback execution failed: %s' % str(e)
|
||||
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
def extend_safe_mode(additional_seconds=None):
|
||||
"""Extend the safe mode countdown timer."""
|
||||
result = {'status': 'error', 'message': ''}
|
||||
|
||||
state = read_persistent_state()
|
||||
if state is None or state.get('mode') != 'safemode':
|
||||
result['message'] = 'Safe mode is not active.'
|
||||
result['status'] = 'not_active'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
if additional_seconds is None:
|
||||
additional_seconds = 60 # Default extension
|
||||
|
||||
additional_seconds = max(10, min(3600, int(additional_seconds)))
|
||||
|
||||
# Update expiry in persistent state
|
||||
new_expiry = state.get('expiry_time', time.time()) + additional_seconds
|
||||
state['expiry_time'] = new_expiry
|
||||
write_persistent_state(state)
|
||||
|
||||
# Kill old timer and start a new one with remaining time
|
||||
kill_timer()
|
||||
remaining = int(new_expiry - time.time())
|
||||
if remaining > 0:
|
||||
rollback_method = state.get('rollback_method', 'reboot')
|
||||
pid, err = _launch_timer_daemon(remaining, rollback_method)
|
||||
if pid is None:
|
||||
log_error('Failed to restart timer: %s' % err)
|
||||
else:
|
||||
remaining = 0
|
||||
|
||||
log_info('Safe mode extended by %d seconds. New remaining: %d seconds.' % (
|
||||
additional_seconds, remaining))
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'message': 'Timer extended by %d seconds. %d seconds remaining.' % (
|
||||
additional_seconds, remaining),
|
||||
'remaining_seconds': remaining,
|
||||
'expiry_time': new_expiry,
|
||||
}
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ensure_volatile_dir()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({'status': 'error', 'message': 'Usage: safemode.py start|confirm|cancel|extend [args]'}))
|
||||
sys.exit(1)
|
||||
|
||||
action = sys.argv[1].lower()
|
||||
|
||||
if action == 'start':
|
||||
timeout = None
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
timeout = int(sys.argv[2])
|
||||
except ValueError:
|
||||
print(json.dumps({'status': 'error', 'message': 'Invalid timeout value: %s' % sys.argv[2]}))
|
||||
sys.exit(1)
|
||||
start_safe_mode(timeout)
|
||||
elif action == 'confirm':
|
||||
confirm_safe_mode()
|
||||
elif action == 'cancel':
|
||||
cancel_safe_mode()
|
||||
elif action == 'extend':
|
||||
extra = None
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
extra = int(sys.argv[2])
|
||||
except ValueError:
|
||||
print(json.dumps({'status': 'error', 'message': 'Invalid seconds value: %s' % sys.argv[2]}))
|
||||
sys.exit(1)
|
||||
extend_safe_mode(extra)
|
||||
else:
|
||||
print(json.dumps({'status': 'error', 'message': 'Unknown action: %s' % action}))
|
||||
sys.exit(1)
|
||||
145
sysutils/autorollback/src/opnsense/scripts/autorollback/status.py
Executable file
145
sysutils/autorollback/src/opnsense/scripts/autorollback/status.py
Executable file
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Status Reporter
|
||||
|
||||
Returns the current state of the auto-rollback system as JSON.
|
||||
Used by the dashboard widget, API, and CLI.
|
||||
|
||||
Usage: status.py (no arguments)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from lib.common import (
|
||||
ensure_volatile_dir,
|
||||
read_model_settings,
|
||||
read_persistent_state, read_session_token,
|
||||
read_timer_pid, is_restore_in_progress,
|
||||
WATCHDOG_FAIL_COUNT_FILE, WATCHDOG_LAST_CONFIG_FILE,
|
||||
)
|
||||
|
||||
|
||||
def get_watchdog_status():
|
||||
"""Get the watchdog subsystem status."""
|
||||
fail_count = 0
|
||||
last_config_time = 0
|
||||
last_config_backup = ''
|
||||
|
||||
try:
|
||||
if os.path.isfile(WATCHDOG_FAIL_COUNT_FILE):
|
||||
with open(WATCHDOG_FAIL_COUNT_FILE, 'r') as f:
|
||||
fail_count = int(f.read().strip())
|
||||
except (ValueError, IOError):
|
||||
pass
|
||||
|
||||
try:
|
||||
if os.path.isfile(WATCHDOG_LAST_CONFIG_FILE):
|
||||
with open(WATCHDOG_LAST_CONFIG_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
last_config_time = data.get('time', 0)
|
||||
last_config_backup = data.get('backup', '')
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
|
||||
return {
|
||||
'fail_count': fail_count,
|
||||
'last_config_change': last_config_time,
|
||||
'last_config_backup': last_config_backup,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ensure_volatile_dir()
|
||||
|
||||
now = time.time()
|
||||
settings = read_model_settings()
|
||||
state = read_persistent_state()
|
||||
watchdog = get_watchdog_status()
|
||||
|
||||
# Determine safe mode status
|
||||
safe_mode_active = False
|
||||
safe_mode_remaining = 0
|
||||
safe_mode_info = {}
|
||||
|
||||
if state and state.get('mode') == 'safemode':
|
||||
expiry = state.get('expiry_time', 0)
|
||||
remaining = max(0, expiry - now)
|
||||
timer_pid = read_timer_pid()
|
||||
safe_mode_active = remaining > 0 or timer_pid is not None
|
||||
|
||||
safe_mode_info = {
|
||||
'backup_file': state.get('backup_file', ''),
|
||||
'backup_revision': state.get('backup_revision', ''),
|
||||
'start_time': state.get('start_time', 0),
|
||||
'expiry_time': expiry,
|
||||
'remaining_seconds': int(remaining),
|
||||
'timeout': state.get('timeout', 0),
|
||||
'rollback_method': state.get('rollback_method', 'reboot'),
|
||||
'timer_pid': timer_pid,
|
||||
}
|
||||
safe_mode_remaining = int(remaining)
|
||||
|
||||
# Determine overall system state
|
||||
if is_restore_in_progress():
|
||||
system_state = 'restoring'
|
||||
elif safe_mode_active:
|
||||
system_state = 'safe_mode'
|
||||
elif settings['enabled']:
|
||||
system_state = 'armed'
|
||||
else:
|
||||
system_state = 'disabled'
|
||||
|
||||
result = {
|
||||
'status': 'ok',
|
||||
'timestamp': now,
|
||||
'system_state': system_state,
|
||||
'settings': settings,
|
||||
'safe_mode': {
|
||||
'active': safe_mode_active,
|
||||
'remaining_seconds': safe_mode_remaining,
|
||||
**safe_mode_info,
|
||||
},
|
||||
'watchdog': {
|
||||
'enabled': settings['watchdog_enabled'],
|
||||
**watchdog,
|
||||
},
|
||||
'token': read_session_token(),
|
||||
}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(json.dumps({'status': 'error', 'message': str(e)}))
|
||||
198
sysutils/autorollback/src/opnsense/scripts/autorollback/timer_daemon.py
Executable file
198
sysutils/autorollback/src/opnsense/scripts/autorollback/timer_daemon.py
Executable file
|
|
@ -0,0 +1,198 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Timer Daemon
|
||||
|
||||
This is a background process that counts down and triggers rollback
|
||||
if not killed before expiry. It is the PRIMARY rollback trigger.
|
||||
|
||||
Design:
|
||||
- Launched by safemode.py start
|
||||
- Double-forks to fully detach from configd parent process
|
||||
- Sleeps in 1-second intervals (allows responsive cancellation via SIGTERM)
|
||||
- On expiry: reads the backup path from persistent state and executes rollback
|
||||
- On SIGTERM: exits cleanly (safe mode was confirmed or cancelled)
|
||||
- PID is stored in /var/run/autorollback/timer.pid
|
||||
|
||||
Usage: timer_daemon.py <timeout_seconds> <rollback_method>
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from lib.common import (
|
||||
log_info, log_warning, log_error,
|
||||
read_persistent_state, clear_persistent_state, clear_session_token,
|
||||
clean_timer_pid, write_timer_pid, VOLATILE_DIR
|
||||
)
|
||||
|
||||
# Global flag for clean shutdown
|
||||
_shutdown = False
|
||||
|
||||
|
||||
def handle_sigterm(signum, frame):
|
||||
"""Handle SIGTERM for clean shutdown (safe mode confirmed/cancelled)."""
|
||||
global _shutdown
|
||||
_shutdown = True
|
||||
|
||||
|
||||
def daemonize():
|
||||
"""
|
||||
Double-fork to fully detach from the parent process (configd).
|
||||
|
||||
This ensures the timer daemon survives even if configd restarts,
|
||||
and that configd doesn't block waiting for our exit.
|
||||
"""
|
||||
# First fork — exit parent (returns control to configd)
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent: exit immediately so configd doesn't block
|
||||
os._exit(0)
|
||||
|
||||
# First child: create new session
|
||||
os.setsid()
|
||||
|
||||
# Second fork — prevent reacquiring a controlling terminal
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# First child exits
|
||||
os._exit(0)
|
||||
|
||||
# Second child: the actual daemon process
|
||||
# Redirect standard file descriptors to /dev/null
|
||||
devnull = os.open(os.devnull, os.O_RDWR)
|
||||
try:
|
||||
os.dup2(devnull, 0) # stdin
|
||||
os.dup2(devnull, 1) # stdout
|
||||
os.dup2(devnull, 2) # stderr
|
||||
finally:
|
||||
if devnull > 2:
|
||||
os.close(devnull)
|
||||
|
||||
# Update PID file with our actual daemon PID
|
||||
write_timer_pid(os.getpid())
|
||||
|
||||
|
||||
def run_timer(timeout, rollback_method):
|
||||
"""Main timer loop. Counts down and triggers rollback on expiry."""
|
||||
global _shutdown
|
||||
|
||||
# Register signal handlers
|
||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||
signal.signal(signal.SIGINT, handle_sigterm)
|
||||
|
||||
log_info('Timer daemon started: timeout=%ds, method=%s, pid=%d' % (
|
||||
timeout, rollback_method, os.getpid()))
|
||||
|
||||
# Count down in 1-second intervals
|
||||
elapsed = 0
|
||||
while elapsed < timeout:
|
||||
if _shutdown:
|
||||
log_info('Timer daemon received shutdown signal. Exiting cleanly.')
|
||||
clean_timer_pid()
|
||||
sys.exit(0)
|
||||
|
||||
time.sleep(1)
|
||||
elapsed += 1
|
||||
|
||||
# Timer expired! Time to rollback.
|
||||
log_warning('SAFE MODE TIMER EXPIRED after %d seconds. Initiating rollback.' % timeout)
|
||||
|
||||
# Read the backup file from persistent state
|
||||
state = read_persistent_state()
|
||||
if state is None:
|
||||
log_error('Timer expired but no persistent state found. Someone else handled it.')
|
||||
clean_timer_pid()
|
||||
sys.exit(0)
|
||||
|
||||
backup_file = state.get('backup_file', '')
|
||||
if not backup_file or not os.path.isfile(backup_file):
|
||||
log_error('Timer expired but backup file missing: %s' % backup_file)
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
clean_timer_pid()
|
||||
sys.exit(1)
|
||||
|
||||
# Clear state BEFORE rollback to prevent re-entrancy
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
clean_timer_pid()
|
||||
|
||||
# Execute rollback
|
||||
rollback_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'rollback.py')
|
||||
try:
|
||||
log_info('Executing rollback: backup=%s, method=%s' % (backup_file, rollback_method))
|
||||
result = subprocess.run(
|
||||
[sys.executable, rollback_script, backup_file, rollback_method],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log_error('Rollback script failed: %s' % result.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
log_info('Rollback script completed successfully.')
|
||||
except subprocess.TimeoutExpired:
|
||||
log_error('Rollback script timed out after 300 seconds.')
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
log_error('Rollback execution failed: %s' % str(e))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: timer_daemon.py <timeout_seconds> <rollback_method>', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
timeout = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print('Invalid timeout value: %s' % sys.argv[1], file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if timeout <= 0:
|
||||
print('Timeout must be positive, got: %d' % timeout, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
rollback_method = sys.argv[2]
|
||||
if rollback_method not in ('reboot', 'reload'):
|
||||
rollback_method = 'reboot'
|
||||
|
||||
# Double-fork to fully detach from configd
|
||||
daemonize()
|
||||
|
||||
# Now running as a proper daemon
|
||||
run_timer(timeout, rollback_method)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
354
sysutils/autorollback/src/opnsense/scripts/autorollback/watchdog.py
Executable file
354
sysutils/autorollback/src/opnsense/scripts/autorollback/watchdog.py
Executable file
|
|
@ -0,0 +1,354 @@
|
|||
#!/usr/local/bin/python3
|
||||
"""
|
||||
Copyright (c) 2026 MP Lindsey
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
"""
|
||||
OPNsense Auto Rollback - Connectivity Watchdog
|
||||
|
||||
Called by cron every minute. This is Layer 2 of the safety system:
|
||||
Layer 1: Timer daemon (primary, second-precise)
|
||||
Layer 2: This watchdog (secondary, minute-precise)
|
||||
Layer 3: Early boot recovery (tertiary, crash recovery)
|
||||
|
||||
This script has TWO functions:
|
||||
|
||||
1. CRON SAFETY NET for Safe Mode:
|
||||
If the timer daemon died but safe mode state is still pending and expired,
|
||||
trigger rollback. This catches the case where the timer process crashed.
|
||||
|
||||
2. CONNECTIVITY WATCHDOG (always-on):
|
||||
After any config change, run health checks. If checks fail N consecutive
|
||||
times within the grace period after a config change, rollback to the
|
||||
last known-good config.
|
||||
|
||||
Usage: watchdog.py (no arguments, called by cron)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from lib.common import (
|
||||
log_info, log_warning, log_error,
|
||||
read_model_settings, ensure_volatile_dir,
|
||||
read_persistent_state, clear_persistent_state, clear_session_token,
|
||||
is_restore_in_progress, is_firmware_update_running,
|
||||
get_default_gateway, get_previous_backup,
|
||||
read_timer_pid, kill_timer, clean_timer_pid,
|
||||
write_timer_pid,
|
||||
VOLATILE_DIR, WATCHDOG_FAIL_COUNT_FILE, WATCHDOG_LAST_CONFIG_FILE,
|
||||
CONFIG_XML
|
||||
)
|
||||
|
||||
|
||||
def get_fail_count():
|
||||
"""Read the consecutive failure count."""
|
||||
try:
|
||||
if os.path.isfile(WATCHDOG_FAIL_COUNT_FILE):
|
||||
with open(WATCHDOG_FAIL_COUNT_FILE, 'r') as f:
|
||||
return int(f.read().strip())
|
||||
except (ValueError, IOError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def set_fail_count(count):
|
||||
"""Write the consecutive failure count."""
|
||||
try:
|
||||
with open(WATCHDOG_FAIL_COUNT_FILE, 'w') as f:
|
||||
f.write(str(count))
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
|
||||
def clear_fail_count():
|
||||
"""Reset the failure counter."""
|
||||
try:
|
||||
if os.path.isfile(WATCHDOG_FAIL_COUNT_FILE):
|
||||
os.unlink(WATCHDOG_FAIL_COUNT_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_last_config_change():
|
||||
"""Read the last config change record (time, new backup, previous backup)."""
|
||||
try:
|
||||
if os.path.isfile(WATCHDOG_LAST_CONFIG_FILE):
|
||||
with open(WATCHDOG_LAST_CONFIG_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
return (
|
||||
data.get('time', 0),
|
||||
data.get('backup', ''),
|
||||
data.get('previous_backup', ''),
|
||||
)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return 0, '', ''
|
||||
|
||||
|
||||
def run_health_check(command, pattern, gateway=None):
|
||||
"""
|
||||
Run a health check command and match its output against a pattern.
|
||||
Returns (passed, output).
|
||||
|
||||
Security: gateway is already validated by get_default_gateway() via
|
||||
ipaddress.ip_address(). We still use shlex.quote() for defense-in-depth
|
||||
since the command runs with shell=True.
|
||||
"""
|
||||
if not command:
|
||||
return True, 'No command configured'
|
||||
|
||||
# Substitute %gateway% placeholder with safely quoted value
|
||||
if '%gateway%' in command:
|
||||
if gateway:
|
||||
command = command.replace('%gateway%', shlex.quote(gateway))
|
||||
else:
|
||||
# No gateway available, skip this check
|
||||
return True, 'No gateway available, skipping check'
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command, shell=True,
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
if pattern:
|
||||
try:
|
||||
if re.search(pattern, output):
|
||||
return True, output.strip()[:200]
|
||||
else:
|
||||
return False, 'Pattern "%s" not found in output' % pattern
|
||||
except re.error as e:
|
||||
log_warning('Watchdog: invalid regex pattern "%s": %s — treating as pass' % (pattern, e))
|
||||
return True, 'Invalid pattern (skipped)'
|
||||
else:
|
||||
# No pattern — just check exit code
|
||||
return result.returncode == 0, output.strip()[:200]
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, 'Command timed out after 15 seconds'
|
||||
except Exception as e:
|
||||
return False, 'Command error: %s' % str(e)
|
||||
|
||||
|
||||
def check_safe_mode_expired():
|
||||
"""
|
||||
CRON SAFETY NET: Check if safe mode timer expired but daemon died.
|
||||
This is the secondary trigger — catches crashed timer daemons.
|
||||
"""
|
||||
state = read_persistent_state()
|
||||
if state is None or state.get('mode') != 'safemode':
|
||||
return False
|
||||
|
||||
expiry = state.get('expiry_time', 0)
|
||||
now = time.time()
|
||||
|
||||
if now < expiry:
|
||||
# Not expired yet — check if timer daemon is still alive
|
||||
if read_timer_pid() is None:
|
||||
remaining = int(expiry - now)
|
||||
log_warning('Safe mode timer daemon died! %d seconds remaining. Restarting timer.' % remaining)
|
||||
# Restart the timer daemon
|
||||
rollback_method = state.get('rollback_method', 'reboot')
|
||||
timer_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'timer_daemon.py')
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, timer_script, str(remaining), rollback_method],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
# Don't write PID here — the daemon writes its own after double-fork.
|
||||
# The Popen PID is the pre-fork process which exits immediately.
|
||||
log_info('Timer daemon restarted with %d seconds remaining' % remaining)
|
||||
except Exception as e:
|
||||
log_error('Failed to restart timer daemon: %s' % str(e))
|
||||
return False
|
||||
|
||||
# Timer expired and daemon is not running — we need to rollback!
|
||||
log_warning('CRON SAFETY NET: Safe mode expired %d seconds ago. Timer daemon missing. Triggering rollback.' % (
|
||||
int(now - expiry)))
|
||||
|
||||
backup_file = state.get('backup_file', '')
|
||||
rollback_method = state.get('rollback_method', 'reboot')
|
||||
|
||||
if not backup_file or not os.path.isfile(backup_file):
|
||||
log_error('Cannot rollback: backup file missing: %s' % backup_file)
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
return True
|
||||
|
||||
# Clear state before rollback
|
||||
clear_persistent_state()
|
||||
clear_session_token()
|
||||
clean_timer_pid()
|
||||
|
||||
# Execute rollback
|
||||
rollback_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'rollback.py')
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, rollback_script, backup_file, rollback_method],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True, timeout=300
|
||||
)
|
||||
except Exception as e:
|
||||
log_error('Cron safety net rollback failed: %s' % str(e))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def run_watchdog(settings):
|
||||
"""
|
||||
CONNECTIVITY WATCHDOG: Run health checks after config changes.
|
||||
"""
|
||||
last_change_time, last_backup, previous_backup = get_last_config_change()
|
||||
|
||||
if last_change_time == 0:
|
||||
# No recent config change recorded — nothing to watch
|
||||
clear_fail_count()
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
age = now - last_change_time
|
||||
grace = settings['grace_period']
|
||||
|
||||
# Only run checks within the grace period after a config change
|
||||
if age > grace + 300:
|
||||
# More than grace+5min since last change — stop watching
|
||||
clear_fail_count()
|
||||
return
|
||||
|
||||
# Still within grace period — skip checks until grace period elapses
|
||||
if age < grace:
|
||||
clear_fail_count() # Reset stale count from previous config change
|
||||
return
|
||||
|
||||
# Run health checks
|
||||
gateway = get_default_gateway()
|
||||
|
||||
check1_ok, check1_msg = run_health_check(
|
||||
settings['check_command'], settings['check_pattern'], gateway)
|
||||
|
||||
check2_ok = True
|
||||
check2_msg = ''
|
||||
if settings.get('check_command_2'):
|
||||
check2_ok, check2_msg = run_health_check(
|
||||
settings['check_command_2'], settings['check_pattern_2'], gateway)
|
||||
|
||||
all_ok = check1_ok and check2_ok
|
||||
|
||||
if all_ok:
|
||||
fails = get_fail_count()
|
||||
if fails > 0:
|
||||
log_info('Watchdog: health check recovered after %d failures' % fails)
|
||||
clear_fail_count()
|
||||
return
|
||||
|
||||
# Check failed
|
||||
fails = get_fail_count() + 1
|
||||
set_fail_count(fails)
|
||||
|
||||
log_warning('Watchdog: health check failed (%d/%d). Check1: %s. Check2: %s' % (
|
||||
fails, settings['fail_threshold'],
|
||||
check1_msg if not check1_ok else 'OK',
|
||||
check2_msg if not check2_ok else 'OK'))
|
||||
|
||||
if fails >= settings['fail_threshold']:
|
||||
log_warning('WATCHDOG: Failure threshold reached (%d/%d). Triggering rollback!' % (
|
||||
fails, settings['fail_threshold']))
|
||||
|
||||
# Find the correct backup to restore — the one BEFORE the config change
|
||||
# that broke connectivity (previous_backup), NOT the new one.
|
||||
backup_file = None
|
||||
if previous_backup and os.path.isfile(previous_backup):
|
||||
backup_file = previous_backup
|
||||
log_info('Watchdog: rolling back to pre-change backup: %s' % backup_file)
|
||||
else:
|
||||
# Fallback: try to find the second-most-recent backup
|
||||
backup_file = get_previous_backup()
|
||||
if backup_file:
|
||||
log_info('Watchdog: rolling back to previous backup: %s' % backup_file)
|
||||
else:
|
||||
log_error('Watchdog: No suitable backup file available for rollback')
|
||||
clear_fail_count()
|
||||
return
|
||||
|
||||
rollback_method = settings['rollback_method']
|
||||
clear_fail_count()
|
||||
|
||||
# Execute rollback
|
||||
rollback_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'rollback.py')
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, rollback_script, backup_file, rollback_method],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True, timeout=300
|
||||
)
|
||||
except Exception as e:
|
||||
log_error('Watchdog rollback failed: %s' % str(e))
|
||||
|
||||
|
||||
def main():
|
||||
result = {'status': 'ok', 'checks': []}
|
||||
|
||||
# Skip if restore is in progress (re-entrancy guard)
|
||||
if is_restore_in_progress():
|
||||
result['message'] = 'Restore in progress, skipping watchdog'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
# Skip during firmware updates
|
||||
if is_firmware_update_running():
|
||||
result['message'] = 'Firmware update in progress, skipping watchdog'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
# Check 1: Safe mode cron safety net
|
||||
if check_safe_mode_expired():
|
||||
result['message'] = 'Safe mode expired — rollback triggered by cron safety net'
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
# Check 2: Connectivity watchdog
|
||||
settings = read_model_settings()
|
||||
if settings['enabled'] and settings['watchdog_enabled']:
|
||||
run_watchdog(settings)
|
||||
result['message'] = 'Watchdog check completed'
|
||||
else:
|
||||
result['message'] = 'Watchdog disabled'
|
||||
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ensure_volatile_dir()
|
||||
main()
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
[safemode.start]
|
||||
command:/usr/local/opnsense/scripts/autorollback/safemode.py start
|
||||
parameters:%s
|
||||
type:script_output
|
||||
message:Starting auto-rollback safe mode
|
||||
description:Start safe mode with configuration snapshot
|
||||
|
||||
[safemode.confirm]
|
||||
command:/usr/local/opnsense/scripts/autorollback/safemode.py confirm
|
||||
parameters:
|
||||
type:script_output
|
||||
message:Confirming safe mode changes
|
||||
description:Confirm configuration changes and exit safe mode
|
||||
|
||||
[safemode.cancel]
|
||||
command:/usr/local/opnsense/scripts/autorollback/safemode.py cancel
|
||||
parameters:
|
||||
type:script_output
|
||||
message:Cancelling safe mode - reverting changes
|
||||
description:Cancel safe mode and revert to previous configuration
|
||||
|
||||
[safemode.extend]
|
||||
command:/usr/local/opnsense/scripts/autorollback/safemode.py extend
|
||||
parameters:%s
|
||||
type:script_output
|
||||
message:Extending safe mode timer
|
||||
description:Extend the safe mode countdown timer
|
||||
|
||||
[rollback.execute]
|
||||
command:/usr/local/opnsense/scripts/autorollback/rollback.py
|
||||
parameters:%s
|
||||
type:script_output
|
||||
message:Executing configuration rollback
|
||||
description:Roll back to a previous configuration
|
||||
|
||||
[watchdog.check]
|
||||
command:/usr/local/opnsense/scripts/autorollback/watchdog.py
|
||||
parameters:
|
||||
type:script_output
|
||||
message:Running watchdog health check
|
||||
description:Connectivity watchdog health check
|
||||
|
||||
[status]
|
||||
command:/usr/local/opnsense/scripts/autorollback/status.py
|
||||
parameters:
|
||||
type:script_output
|
||||
message:Getting auto-rollback status
|
||||
description:Report current auto-rollback state
|
||||
305
sysutils/autorollback/src/opnsense/www/js/autorollback_banner.js
Normal file
305
sysutils/autorollback/src/opnsense/www/js/autorollback_banner.js
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/*
|
||||
* Copyright (C) 2026 MP Lindsey
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OPNsense Auto Rollback - Persistent Global Banner
|
||||
*
|
||||
* This script injects a countdown banner at the top of EVERY page when
|
||||
* safe mode is active. It polls the status API and shows/hides the banner
|
||||
* dynamically. Includes confirm/revert buttons for immediate action
|
||||
* without navigating to the plugin settings page.
|
||||
*
|
||||
* This file should be included in the base layout template or via a
|
||||
* system hook that adds JavaScript to every page.
|
||||
*
|
||||
* Design: Non-intrusive but unmissable. Fixed position below the navbar,
|
||||
* full-width, with a pulsing amber background during safe mode.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Don't double-initialize
|
||||
if (window._autorollbackBannerInit) return;
|
||||
window._autorollbackBannerInit = true;
|
||||
|
||||
const POLL_INTERVAL_IDLE = 10000; // 10s when not in safe mode
|
||||
const POLL_INTERVAL_ACTIVE = 1000; // 1s during safe mode
|
||||
const BANNER_ID = 'autorollback-global-banner';
|
||||
|
||||
let pollTimer = null;
|
||||
let currentPollInterval = POLL_INTERVAL_IDLE;
|
||||
let bannerElement = null;
|
||||
|
||||
function createBanner() {
|
||||
if (document.getElementById(BANNER_ID)) return;
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.id = BANNER_ID;
|
||||
banner.innerHTML = `
|
||||
<style>
|
||||
#${BANNER_ID} {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 99999;
|
||||
display: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transition: transform 0.3s ease;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
#${BANNER_ID}.visible {
|
||||
display: block;
|
||||
transform: translateY(0);
|
||||
}
|
||||
#${BANNER_ID} .arb-inner {
|
||||
background: linear-gradient(135deg, #f0ad4e 0%, #ec971f 100%);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
min-height: 44px;
|
||||
}
|
||||
#${BANNER_ID}.danger .arb-inner {
|
||||
background: linear-gradient(135deg, #d9534f 0%, #c9302c 100%);
|
||||
}
|
||||
#${BANNER_ID} .arb-icon {
|
||||
font-size: 18px;
|
||||
animation: arb-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes arb-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
#${BANNER_ID} .arb-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
#${BANNER_ID} .arb-countdown {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
#${BANNER_ID} .arb-btn {
|
||||
padding: 5px 16px;
|
||||
border: 2px solid rgba(255,255,255,0.8);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
#${BANNER_ID} .arb-btn:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-color: #fff;
|
||||
}
|
||||
#${BANNER_ID} .arb-btn-confirm {
|
||||
background: rgba(255,255,255,0.25);
|
||||
border-color: #fff;
|
||||
}
|
||||
#${BANNER_ID} .arb-btn-confirm:hover {
|
||||
background: rgba(255,255,255,0.4);
|
||||
}
|
||||
#${BANNER_ID} .arb-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
transition: width 1s linear;
|
||||
border-radius: 0 2px 0 0;
|
||||
}
|
||||
</style>
|
||||
<div class="arb-inner" style="position: relative;">
|
||||
<span class="arb-icon">⚠</span>
|
||||
<span class="arb-text">Safe Mode Active</span>
|
||||
<span class="arb-countdown" id="arb-countdown">--</span>
|
||||
<button class="arb-btn arb-btn-confirm" id="arb-confirm" title="Accept the current configuration">
|
||||
✓ CONFIRM
|
||||
</button>
|
||||
<button class="arb-btn" id="arb-revert" title="Revert to the previous configuration">
|
||||
↺ REVERT
|
||||
</button>
|
||||
<button class="arb-btn" id="arb-extend" title="Add 60 seconds to the timer">
|
||||
+60s
|
||||
</button>
|
||||
<div class="arb-progress" id="arb-progress"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(banner);
|
||||
bannerElement = banner;
|
||||
|
||||
// Button event listeners
|
||||
document.getElementById('arb-confirm').addEventListener('click', function() {
|
||||
this.disabled = true;
|
||||
this.textContent = 'Confirming...';
|
||||
apiPost('confirm', function() {
|
||||
pollStatus();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('arb-revert').addEventListener('click', function() {
|
||||
if (confirm('Revert to the previous configuration?\nThe system may reboot.')) {
|
||||
this.disabled = true;
|
||||
this.textContent = 'Reverting...';
|
||||
apiPost('cancel', function() {
|
||||
pollStatus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('arb-extend').addEventListener('click', function() {
|
||||
apiPost('extend', function() {
|
||||
pollStatus();
|
||||
}, {seconds: 60});
|
||||
});
|
||||
}
|
||||
|
||||
function showBanner(remaining, total) {
|
||||
if (!bannerElement) createBanner();
|
||||
|
||||
bannerElement.classList.add('visible');
|
||||
|
||||
// Danger mode when under 20% time remaining
|
||||
let pct = total > 0 ? remaining / total : 0;
|
||||
if (pct <= 0.2) {
|
||||
bannerElement.classList.add('danger');
|
||||
} else {
|
||||
bannerElement.classList.remove('danger');
|
||||
}
|
||||
|
||||
// Update countdown
|
||||
let mins = Math.floor(remaining / 60);
|
||||
let secs = remaining % 60;
|
||||
let display = mins > 0
|
||||
? mins + 'm ' + String(secs).padStart(2, '0') + 's'
|
||||
: secs + 's';
|
||||
document.getElementById('arb-countdown').textContent = display;
|
||||
|
||||
// Progress bar
|
||||
document.getElementById('arb-progress').style.width = (pct * 100) + '%';
|
||||
|
||||
// Re-enable buttons
|
||||
let confirmBtn = document.getElementById('arb-confirm');
|
||||
let revertBtn = document.getElementById('arb-revert');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.innerHTML = '✓ CONFIRM';
|
||||
revertBtn.disabled = false;
|
||||
revertBtn.innerHTML = '↺ REVERT';
|
||||
|
||||
// Push body content down to avoid overlap
|
||||
document.body.style.paddingTop = bannerElement.offsetHeight + 'px';
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
if (bannerElement) {
|
||||
bannerElement.classList.remove('visible');
|
||||
document.body.style.paddingTop = '';
|
||||
}
|
||||
}
|
||||
|
||||
function apiPost(action, callback, data) {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/autorollback/service/' + action, true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
|
||||
// Include CSRF token if available (OPNsense uses jQuery for this)
|
||||
let csrfToken = '';
|
||||
if (typeof $ !== 'undefined' && $.ajaxSettings && $.ajaxSettings.headers) {
|
||||
csrfToken = $.ajaxSettings.headers['X-CSRFToken'] || '';
|
||||
}
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
|
||||
xhr.onload = function() {
|
||||
if (callback) callback();
|
||||
};
|
||||
|
||||
let body = '';
|
||||
if (data) {
|
||||
body = Object.keys(data).map(function(k) {
|
||||
return encodeURIComponent(k) + '=' + encodeURIComponent(data[k]);
|
||||
}).join('&');
|
||||
}
|
||||
xhr.send(body);
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/autorollback/service/status', true);
|
||||
xhr.onload = function() {
|
||||
try {
|
||||
let data = JSON.parse(xhr.responseText);
|
||||
let state = data.system_state || 'disabled';
|
||||
let safeMode = data.safe_mode || {};
|
||||
|
||||
if (state === 'safe_mode' && safeMode.remaining_seconds > 0) {
|
||||
showBanner(safeMode.remaining_seconds, safeMode.timeout || 120);
|
||||
setPolling(POLL_INTERVAL_ACTIVE);
|
||||
} else {
|
||||
hideBanner();
|
||||
setPolling(POLL_INTERVAL_IDLE);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore parse errors — API might be temporarily unavailable
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
// API unreachable — could be mid-rollback, keep polling
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function setPolling(interval) {
|
||||
if (interval === currentPollInterval && pollTimer) return;
|
||||
currentPollInterval = interval;
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(pollStatus, interval);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
pollStatus();
|
||||
setPolling(POLL_INTERVAL_IDLE);
|
||||
});
|
||||
} else {
|
||||
pollStatus();
|
||||
setPolling(POLL_INTERVAL_IDLE);
|
||||
}
|
||||
})();
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
/*
|
||||
* Copyright (C) 2026 MP Lindsey
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice,
|
||||
* this list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in the
|
||||
* documentation and/or other materials provided with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OPNsense Auto Rollback - Dashboard Widget
|
||||
*
|
||||
* Shows real-time safe mode status with countdown, one-click
|
||||
* start/confirm/cancel controls directly from the dashboard.
|
||||
*/
|
||||
export default class AutoRollback extends BaseWidget {
|
||||
constructor() {
|
||||
super();
|
||||
this.tickTimeout = 2;
|
||||
}
|
||||
|
||||
getMarkup() {
|
||||
return $(`
|
||||
<div id="autorollback-widget" style="padding: 8px 12px; font-family: inherit;">
|
||||
<style>
|
||||
@keyframes arw-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
<!-- Status indicator -->
|
||||
<div id="arw-status-row" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span id="arw-badge" style="
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.4px; background: #e9ecef; color: #495057;
|
||||
">
|
||||
<span id="arw-dot" style="
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #6c757d; display: inline-block;
|
||||
"></span>
|
||||
<span id="arw-badge-text">Loading</span>
|
||||
</span>
|
||||
<span id="arw-method" style="font-size: 11px; color: #868e96;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Countdown (visible in safe mode) -->
|
||||
<div id="arw-countdown-section" style="display: none; text-align: center; margin: 8px 0;">
|
||||
<div id="arw-countdown" style="
|
||||
font-size: 36px; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.5px; line-height: 1.1;
|
||||
">0<span style="font-size: 13px; font-weight: 400; color: #868e96;">s</span></div>
|
||||
<div style="height: 4px; background: #e9ecef; border-radius: 2px; margin: 6px 0; overflow: hidden;">
|
||||
<div id="arw-bar" style="
|
||||
height: 100%; width: 100%; border-radius: 2px;
|
||||
background: #5cb85c; transition: width 1s linear, background 0.5s ease;
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div id="arw-actions" style="display: flex; gap: 6px; flex-wrap: wrap;">
|
||||
<button id="arw-btn-start" class="btn btn-primary btn-xs" style="flex: 1; min-width: 80px; display: none;">
|
||||
<i class="fa fa-shield"></i> Safe Mode
|
||||
</button>
|
||||
<button id="arw-btn-confirm" class="btn btn-success btn-xs" style="flex: 1; min-width: 80px; display: none;">
|
||||
<i class="fa fa-check"></i> Confirm
|
||||
</button>
|
||||
<button id="arw-btn-revert" class="btn btn-danger btn-xs" style="flex: 1; min-width: 80px; display: none;">
|
||||
<i class="fa fa-undo"></i> Revert
|
||||
</button>
|
||||
<button id="arw-btn-extend" class="btn btn-default btn-xs" style="min-width: 50px; display: none;">
|
||||
+60s
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Watchdog info -->
|
||||
<div id="arw-watchdog" style="margin-top: 8px; font-size: 11px; color: #868e96; display: none;">
|
||||
<i class="fa fa-heartbeat"></i>
|
||||
<span id="arw-watchdog-text">Watchdog: monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async onMarkupRendered() {
|
||||
const self = this;
|
||||
|
||||
$('#arw-btn-start').on('click', async function() {
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
await self.ajaxCall('/api/autorollback/service/start', {}, 'POST');
|
||||
self.tickTimeout = 1;
|
||||
} catch(e) { /* ignore */ }
|
||||
await self.onWidgetTick();
|
||||
});
|
||||
|
||||
$('#arw-btn-confirm').on('click', async function() {
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
await self.ajaxCall('/api/autorollback/service/confirm', {}, 'POST');
|
||||
self.tickTimeout = 2;
|
||||
} catch(e) { /* ignore */ }
|
||||
await self.onWidgetTick();
|
||||
});
|
||||
|
||||
$('#arw-btn-revert').on('click', async function() {
|
||||
if (confirm('Revert to previous configuration? The system may reboot.')) {
|
||||
$(this).prop('disabled', true);
|
||||
try {
|
||||
await self.ajaxCall('/api/autorollback/service/cancel', {}, 'POST');
|
||||
} catch(e) { /* ignore */ }
|
||||
await self.onWidgetTick();
|
||||
}
|
||||
});
|
||||
|
||||
$('#arw-btn-extend').on('click', async function() {
|
||||
try {
|
||||
await self.ajaxCall('/api/autorollback/service/extend', {seconds: 60}, 'POST');
|
||||
} catch(e) { /* ignore */ }
|
||||
await self.onWidgetTick();
|
||||
});
|
||||
}
|
||||
|
||||
async onWidgetTick() {
|
||||
try {
|
||||
const data = await this.ajaxCall('/api/autorollback/service/status');
|
||||
if (!data || data.status === 'error') {
|
||||
this._renderError();
|
||||
return;
|
||||
}
|
||||
this._renderStatus(data);
|
||||
} catch(e) {
|
||||
this._renderError();
|
||||
}
|
||||
}
|
||||
|
||||
_renderStatus(data) {
|
||||
const state = data.system_state || 'disabled';
|
||||
const safeMode = data.safe_mode || {};
|
||||
const watchdog = data.watchdog || {};
|
||||
|
||||
const badge = $('#arw-badge');
|
||||
const dot = $('#arw-dot');
|
||||
const badgeText = $('#arw-badge-text');
|
||||
|
||||
badge.css({'background': '#e9ecef', 'color': '#495057'});
|
||||
dot.css({'background': '#6c757d', 'animation': 'none'});
|
||||
|
||||
if (state === 'safe_mode') {
|
||||
badge.css({'background': '#fff3cd', 'color': '#856404'});
|
||||
dot.css({'background': '#f0ad4e', 'animation': 'arw-blink 1s infinite'});
|
||||
badgeText.text('Safe Mode');
|
||||
this.tickTimeout = 1;
|
||||
} else if (state === 'restoring') {
|
||||
badge.css({'background': '#f8d7da', 'color': '#721c24'});
|
||||
dot.css({'background': '#d9534f', 'animation': 'arw-blink 0.5s infinite'});
|
||||
badgeText.text('Restoring');
|
||||
} else if (state === 'armed') {
|
||||
badge.css({'background': '#d4edda', 'color': '#155724'});
|
||||
dot.css({'background': '#28a745'});
|
||||
badgeText.text('Armed');
|
||||
this.tickTimeout = 5;
|
||||
} else {
|
||||
badgeText.text('Disabled');
|
||||
this.tickTimeout = 10;
|
||||
}
|
||||
|
||||
const method = data.settings?.rollback_method || '';
|
||||
$('#arw-method').text(method === 'reboot' ? 'reboot' : method === 'reload' ? 'reload' : '');
|
||||
|
||||
if (state === 'safe_mode' && safeMode.remaining_seconds > 0) {
|
||||
const remaining = Math.round(safeMode.remaining_seconds);
|
||||
const total = safeMode.timeout || 120;
|
||||
const pct = total > 0 ? (remaining / total) * 100 : 0;
|
||||
|
||||
let mins = Math.floor(remaining / 60);
|
||||
let secs = remaining % 60;
|
||||
let display = mins > 0
|
||||
? `${mins}<span style="font-size:13px;font-weight:400;color:#868e96">m</span> ${String(secs).padStart(2,'0')}<span style="font-size:13px;font-weight:400;color:#868e96">s</span>`
|
||||
: `${secs}<span style="font-size:13px;font-weight:400;color:#868e96">s</span>`;
|
||||
$('#arw-countdown').html(display);
|
||||
|
||||
let barColor = pct > 50 ? '#5cb85c' : (pct > 20 ? '#f0ad4e' : '#d9534f');
|
||||
$('#arw-bar').css({'width': pct + '%', 'background': barColor});
|
||||
|
||||
$('#arw-countdown-section').show();
|
||||
} else {
|
||||
$('#arw-countdown-section').hide();
|
||||
}
|
||||
|
||||
$('#arw-btn-start').toggle(state === 'armed').prop('disabled', false);
|
||||
$('#arw-btn-confirm').toggle(state === 'safe_mode').prop('disabled', false);
|
||||
$('#arw-btn-revert').toggle(state === 'safe_mode').prop('disabled', false);
|
||||
$('#arw-btn-extend').toggle(state === 'safe_mode').prop('disabled', false);
|
||||
|
||||
if (watchdog.enabled) {
|
||||
let wdText = 'Watchdog: monitoring';
|
||||
if (watchdog.fail_count > 0) {
|
||||
wdText = `Watchdog: ${watchdog.fail_count} failure(s)`;
|
||||
}
|
||||
$('#arw-watchdog-text').text(wdText);
|
||||
$('#arw-watchdog').show();
|
||||
} else {
|
||||
$('#arw-watchdog').hide();
|
||||
}
|
||||
}
|
||||
|
||||
_renderError() {
|
||||
$('#arw-badge').css({'background': '#f8d7da', 'color': '#721c24'});
|
||||
$('#arw-badge-text').text('Error');
|
||||
$('#arw-countdown-section').hide();
|
||||
$('#arw-btn-start, #arw-btn-confirm, #arw-btn-revert, #arw-btn-extend').hide();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<metadata>
|
||||
<AutoRollback>
|
||||
<filename>AutoRollback.js</filename>
|
||||
<endpoints>
|
||||
<endpoint>/api/autorollback/service/status</endpoint>
|
||||
</endpoints>
|
||||
<translations>
|
||||
<title>Auto Rollback</title>
|
||||
</translations>
|
||||
</AutoRollback>
|
||||
</metadata>
|
||||
Loading…
Reference in a new issue