Skip to content

Instantly share code, notes, and snippets.

@lavacano
Forked from spali/10-wancarp
Last active July 18, 2025 07:55
Show Gist options
  • Save lavacano/a678e65d31df9bec344e572461ed3e10 to your computer and use it in GitHub Desktop.
Save lavacano/a678e65d31df9bec344e572461ed3e10 to your computer and use it in GitHub Desktop.
Disable WAN Interface on CARP Backup
#!/usr/local/bin/php
<?php
declare(strict_types=1);
/*
* OPNsense HA Failover Script - FINAL CORRECTED VERSION
* Makes targeted changes for WAN, Tunnels, and Services.
* Controls services that are not CARP-aware.
* Correctly handles default route on backup node.
* Fixed: CARP service remains stopped on backup nodes (passive state).
*/
require_once 'config.inc';
require_once 'interfaces.inc';
require_once 'util.inc';
require_once 'system.inc';
require_once 'filter.inc';
use OPNsense\Core\Backend;
use OPNsense\Core\Config;
// =========================================================================
// == Configuration
// =========================================================================
const IFKEY = 'wan';
const TBROKER_IFKEY = 'opt1';
const WAN_IP_V4 = '69.420.xxx.yyy';
const WAN_SUBNET_V4 = '27';
const WAN_GW_NAME = 'WAN_STATIC';
const TBROKER_GW_NAME = 'TUNNELBROKER_TUNNELV6';
const LAN_VIP_V4 = '10.10.10.1';
const LAN_VIP_V6 = '2600:1337:10::1';
const LOCK_FILE = '/tmp/wan_failover.lock';
const BACKUP_GUARD_FILE = '/tmp/ha_in_backup_state';
const IP_NONE = 'none';
const INTERFACE_SETTLE_TIME = 3;
const GATEWAY_SETTLE_TIME = 5;
// Services that should only run on the MASTER node (not CARP-aware)
const HA_CONTROLLED_SERVICES = [
'lldpd',
'snmpd',
'igmpproxy'
];
// =========================================================================
// == Core Functions
// =========================================================================
function log_failover(string $message, int $priority = LOG_NOTICE): void
{
log_msg("WAN Failover: {$message}", $priority);
}
function acquire_lock()
{
$lock_handle = fopen(LOCK_FILE, 'w');
if (!$lock_handle || !flock($lock_handle, LOCK_EX | LOCK_NB)) {
log_failover('Could not acquire lock file. Another instance may be running.', LOG_WARNING);
exit(1);
}
return $lock_handle;
}
function setup_shutdown_handler($lock_handle): void
{
register_shutdown_function(function () use ($lock_handle) {
if (is_resource($lock_handle)) {
flock($lock_handle, LOCK_UN);
fclose($lock_handle);
}
@unlink(LOCK_FILE);
});
}
function validate_arguments(array $argv): array
{
$subsystem = $argv[1] ?? '';
$type = $argv[2] ?? '';
if (!in_array($type, ['MASTER', 'BACKUP'], true)) {
exit(0);
}
if (!preg_match('/^[a-z0-9_]+@\S+$/i', $subsystem)) {
log_failover("Malformed subsystem argument: '{$subsystem}'.", LOG_WARNING);
exit(0);
}
return [$subsystem, $type];
}
function configure_single_interface(Backend $backend, string $ifkey): bool
{
log_failover("Configuring interface '{$ifkey}'...");
try {
$result = $backend->configdRun("interface reconfigure {$ifkey}");
if ($result === null) {
log_failover("Failed to reconfigure interface '{$ifkey}'", LOG_WARNING);
return false;
}
sleep(INTERFACE_SETTLE_TIME);
log_failover("Interface '{$ifkey}' configured successfully.");
return true;
} catch (Exception $e) {
log_failover("Exception configuring '{$ifkey}': " . $e->getMessage(), LOG_ERR);
return false;
}
}
function control_services(Backend $backend, bool $is_master): void
{
$action = $is_master ? 'start' : 'stop';
log_failover("Performing '{$action}' for HA controlled services...");
foreach (HA_CONTROLLED_SERVICES as $service) {
try {
$result = $backend->configdRun("service {$action} {$service}");
if ($result === null) {
log_failover("Failed to {$action} service '{$service}'", LOG_WARNING);
} else {
log_failover("Service '{$service}' {$action} command sent successfully.");
}
} catch (Exception $e) {
log_failover("Exception with service '{$service}': " . $e->getMessage(), LOG_ERR);
}
}
}
function configure_gateway_monitoring(Backend $backend): bool
{
log_failover("Configuring gateway monitoring...");
try {
$result = $backend->configdRun("system gateway configure");
if ($result === null) {
log_failover("Failed to configure gateway monitoring via backend", LOG_WARNING);
// Fall back to system routing configuration
system_routing_configure();
}
sleep(GATEWAY_SETTLE_TIME);
log_failover("Gateway monitoring configured successfully.");
return true;
} catch (Exception $e) {
log_failover("Exception configuring gateway monitoring: " . $e->getMessage(), LOG_ERR);
return false;
}
}
function remove_default_routes(): void
{
log_failover("Removing default routes from backup node...");
// Remove IPv4 default route
$ipv4_result = mwexec('/sbin/route delete -inet default 2>/dev/null');
if ($ipv4_result === 0) {
log_failover("IPv4 default route removed successfully.");
} else {
log_failover("IPv4 default route removal failed or no route existed.");
}
// Remove IPv6 default route
$ipv6_result = mwexec('/sbin/route delete -inet6 default 2>/dev/null');
if ($ipv6_result === 0) {
log_failover("IPv6 default route removed successfully.");
} else {
log_failover("IPv6 default route removal failed or no route existed.");
}
}
// =========================================================================
// == State Handlers
// =========================================================================
function handle_master_transition(Backend $backend): bool
{
global $config;
log_msg('WAN Failover: MASTER transition starting...', LOG_NOTICE);
// Remove the guard file if it exists (re-arm the backup script)
if (file_exists(BACKUP_GUARD_FILE)) {
@unlink(BACKUP_GUARD_FILE);
log_failover("Removed backup guard file - re-arming failover script.");
}
// Stop CARP to prevent flapping during interface configuration
log_failover("Temporarily stopping CARP service...");
$backend->configdRun("service stop carp");
sleep(2); // Give the service a moment to stop
// 1. Set interface configs for MASTER state
$config['interfaces'][IFKEY]['enable'] = '1';
$config['interfaces'][IFKEY]['ipaddr'] = WAN_IP_V4;
$config['interfaces'][IFKEY]['subnet'] = WAN_SUBNET_V4;
$config['interfaces'][IFKEY]['gateway'] = WAN_GW_NAME;
if (!empty(TBROKER_IFKEY)) {
$config['interfaces'][TBROKER_IFKEY]['enable'] = '1';
}
// 2. Write the config changes to disk
$config_instance = Config::getInstance();
$config_instance->lock();
try {
if (write_config('WAN Failover: MASTER transition') === false) {
log_failover('Failed to write configuration', LOG_ERR);
return false;
}
} finally {
$config_instance->unlock();
}
// 3. Apply interface changes
$success = true;
if (!configure_single_interface($backend, IFKEY)) {
$success = false;
}
if (!empty(TBROKER_IFKEY)) {
if (!configure_single_interface($backend, TBROKER_IFKEY)) {
log_failover('Tunnel interface configuration failed, but continuing...', LOG_WARNING);
}
}
// 4. Start services that should only run on MASTER
control_services($backend, true);
// 5. Configure routing and gateway monitoring
system_routing_configure();
sleep(5); // Wait for routing table to settle
configure_gateway_monitoring($backend);
// 6. Restart CARP now that all configurations are stable
log_failover("Restarting CARP service...");
$backend->configdRun("service start carp");
log_failover('MASTER transition complete.');
return $success;
}
function handle_backup_transition(Backend $backend): bool
{
global $config;
// Check if the guard file exists. If so, we're already in BACKUP state.
if (file_exists(BACKUP_GUARD_FILE)) {
log_failover("Guard file exists - already in BACKUP state. Exiting to prevent loop.");
return true;
}
log_msg('WAN Failover: BACKUP transition starting...', LOG_NOTICE);
// 1. Stop services that should only run on MASTER
control_services($backend, false);
// Stop CARP to prevent it from reacting to the interface going down
log_failover("Stopping CARP service to maintain passive state...");
$backend->configdRun("service stop carp");
sleep(2);
// 2. Set interface configs for BACKUP state
$config['interfaces'][IFKEY]['ipaddr'] = IP_NONE;
unset($config['interfaces'][IFKEY]['enable']);
unset($config['interfaces'][IFKEY]['gateway']);
if (!empty(TBROKER_IFKEY)) {
unset($config['interfaces'][TBROKER_IFKEY]['enable']);
}
// 3. Write the config changes to disk
$config_instance = Config::getInstance();
$config_instance->lock();
try {
if (write_config('WAN Failover: BACKUP transition') === false) {
log_failover('Failed to write configuration', LOG_ERR);
return false;
}
} finally {
$config_instance->unlock();
}
// 4. Apply interface changes
$success = true;
if (!configure_single_interface($backend, IFKEY)) {
$success = false;
}
if (!empty(TBROKER_IFKEY)) {
if (!configure_single_interface($backend, TBROKER_IFKEY)) {
log_failover('Tunnel interface configuration failed, but continuing...', LOG_WARNING);
}
}
// 5. Explicitly remove the default route to prevent WAN traffic leakage
remove_default_routes();
// Create the guard file to prevent the script from running again
if (touch(BACKUP_GUARD_FILE)) {
log_failover("Created backup guard file to prevent script loops.");
} else {
log_failover("Failed to create backup guard file.", LOG_WARNING);
}
// DO NOT RESTART CARP HERE - let the node remain passive
log_failover('BACKUP transition complete. CARP service remains stopped to maintain passive state.');
return $success;
}
// =========================================================================
// == Main Execution
// =========================================================================
$lock_handle = acquire_lock();
setup_shutdown_handler($lock_handle);
[$subsystem, $type] = validate_arguments($argv);
try {
$backend = new Backend();
$success = ($type === 'MASTER') ? handle_master_transition($backend) : handle_backup_transition($backend);
exit($success ? 0 : 1);
} catch (Exception $e) {
log_failover('Unexpected error: ' . $e->getMessage(), LOG_CRIT);
exit(1);
}
#!/usr/local/bin/php
<?php
/*
* OPNsense HA Failover Script for Single WAN IP (v2.9)
*
* v2.9 - 2025-07-12
* - Added optional, independent service management for Unbound DNS, AdGuard Home,
* and the DHCP Daemon.
*
* NOTE! - set System > Settings > Tunables > net.inet.carp.init_delay = 60
* Delay CARP startup for 60 seconds to allow interfaces to stabilize.
*/
// #################### CONFIGURATION ####################
$ifkey = 'wan';
$tbroker_ifkey = 'opt1';
// For STATIC: set your IP and subnet. For DHCP: set ipaddr to 'dhcp'.
$wan_ip_v4 = 'A.B.C.100';
$wan_subnet_v4 = '27';
// Names of the gateways to manage
$wan_gw_name = 'WAN_UPLINK_GW';
$tbroker_gw_name = 'IPV6_TUNNEL_GW';
// LAN CARP VIP for backup node internet access
$lan_vip_v4 = '10.10.10.1';
$lan_vip_v6 = '2001:db8:388a:10::1';
// --- Service Management Toggles ---
$manage_dhcpd = true; // Manage the ISC DHCPv4 Server (dhcpd)
$manage_unbound = true; // Manage Unbound DNS
$manage_adguard = false; // Manage AdGuard Home (non-standard plugin)
$verbose_logging = true;
$lock_file = '/tmp/wan_failover.lock';
// #######################################################
require_once("config.inc");
require_once("interfaces.inc");
require_once("util.inc");
require_once("system.inc");
function log_failover($message, $priority = LOG_NOTICE){
global $verbose_logging;
if ($verbose_logging || $priority >= LOG_WARNING) {
log_msg("WAN Failover: " . $message, $priority);
}
}
function configure_gateway($gw_name, $interface, $is_disabled){
global $config;
if (empty($gw_name) || !isset($config['gateways']['gateway_item']) || !is_array($config['gateways']['gateway_item'])) {
return;
}
foreach ($config['gateways']['gateway_item'] as &$gateway) {
if (isset($gateway['name']) && $gateway['name'] == $gw_name) {
$gateway['interface'] = $interface;
if ($is_disabled) {
$gateway['disabled'] = true;
log_failover("Gateway '{$gw_name}' marked as DISABLED on interface '{$interface}'.");
} else {
unset($gateway['disabled']);
log_failover("Gateway '{$gw_name}' marked as ENABLED on interface '{$interface}'.");
}
break;
}
}
}
function handle_master_transition($ifkey, $subsystem){
global $config, $wan_ip_v4, $wan_subnet_v4, $wan_gw_name, $tbroker_gw_name, $tbroker_ifkey, $manage_dhcpd, $manage_unbound, $manage_adguard;
log_msg("WAN Failover: CARP MASTER event on {$subsystem}. Starting transition...", LOG_NOTICE);
// STAGE 1: Enable interfaces
log_failover("MASTER - Stage 1: Enabling interfaces.");
config_read_array();
$config['interfaces'][$ifkey]['enable'] = true;
if (!empty($tbroker_gw_name)) {
$config['interfaces'][$tbroker_ifkey]['enable'] = true;
}
write_config("WAN Failover: Master - Stage 1", false);
interface_configure(false, $ifkey, true, false);
if (!empty($tbroker_gw_name)) {
interface_configure(false, $tbroker_ifkey, true, false);
}
// STAGE 2: Enable and assign gateways
log_failover("MASTER - Stage 2: Enabling and assigning gateways.");
config_read_array();
configure_gateway($wan_gw_name, $ifkey, false);
if (!empty($tbroker_gw_name)) {
configure_gateway($tbroker_gw_name, $tbroker_ifkey, false);
}
write_config("WAN Failover: Master - Stage 2", false);
system_routing_configure();
// STAGE 3: Configure WAN IP
log_failover("MASTER - Stage 3: Configuring WAN IP and linking gateway.");
config_read_array();
$config['interfaces'][$ifkey]['ipaddr'] = $wan_ip_v4;
if ($wan_ip_v4 !== 'dhcp' && !empty($wan_subnet_v4)) {
$config['interfaces'][$ifkey]['subnet'] = $wan_subnet_v4;
}
$config['interfaces'][$ifkey]['gateway'] = $wan_gw_name;
write_config("WAN Failover: Master - Stage 3", false);
interface_configure(false, $ifkey, true, false);
system_routing_configure();
// STAGE 4: Reset TunnelBroker gateway
if (!empty($tbroker_gw_name)) {
log_failover("MASTER - Stage 4: Resetting TunnelBroker gateway.");
sleep(1);
config_read_array();
configure_gateway($tbroker_gw_name, $tbroker_ifkey, true);
write_config("WAN Failover: Master - Stage 4a (Disable Tunnel)", false);
system_routing_configure();
sleep(1);
config_read_array();
configure_gateway($tbroker_gw_name, $tbroker_ifkey, false);
write_config("WAN Failover: Master - Stage 4b (Enable Tunnel)", false);
system_routing_configure();
}
// Finalize: Restart services on the new MASTER node.
if ($manage_dhcpd) {
log_failover("Restarting DHCPD service on MASTER node.");
mwexecf('/usr/local/sbin/pluginctl -s dhcpd restart');
}
if ($manage_unbound) {
log_failover("Restarting Unbound DNS service on MASTER node.");
mwexecf('/usr/local/sbin/pluginctl -s unbound restart');
}
if ($manage_adguard) {
log_failover("Restarting AdGuard Home service on MASTER node.");
mwexecf('/usr/local/etc/rc.d/adguardhome restart');
}
log_failover("MASTER transition complete.");
return true;
}
function handle_backup_transition($ifkey, $subsystem){
global $config, $lan_vip_v4, $lan_vip_v6, $wan_gw_name, $tbroker_gw_name, $tbroker_ifkey, $manage_dhcpd, $manage_unbound, $manage_adguard;
log_msg("WAN Failover: CARP BACKUP event on {$subsystem}. Disabling WAN and gateways.", LOG_NOTICE);
// Stop services on the new BACKUP node.
if ($manage_dhcpd) {
log_failover("Stopping DHCPD service on BACKUP node.");
mwexecf('/usr/local/sbin/pluginctl -s dhcpd stop');
}
if ($manage_unbound) {
log_failover("Stopping Unbound DNS service on BACKUP node.");
mwexecf('/usr/local/sbin/pluginctl -s unbound stop');
}
if ($manage_adguard) {
log_failover("Stopping AdGuard Home service on BACKUP node.");
mwexecf('/usr/local/etc/rc.d/adguardhome stop');
}
$real_wan_if = get_real_interface($ifkey);
if (!empty($real_wan_if)) {
log_failover("Forcing physical interface {$real_wan_if} down.");
mwexecf('/sbin/ifconfig %s down', [$real_wan_if]);
}
log_failover("Killing states on interface '{$ifkey}'.");
mwexecf('/sbin/pfctl -i %s -F states', [$ifkey]);
config_read_array();
$config['interfaces'][$ifkey]['ipaddr'] = 'none';
unset($config['interfaces'][$ifkey]['enable']);
unset($config['interfaces'][$ifkey]['gateway']);
if (!empty($tbroker_gw_name)) {
unset($config['interfaces'][$tbroker_ifkey]['enable']);
}
configure_gateway($wan_gw_name, $ifkey, true);
if (!empty($tbroker_gw_name)) {
configure_gateway($tbroker_gw_name, $tbroker_ifkey, true);
}
if (write_config("WAN Failover: Set to BACKUP", false) === false) {
log_failover("FATAL: Failed to write configuration to disk.", LOG_ERR);
return false;
}
log_failover("Applying light interface configurations...");
interface_configure(false, $ifkey, false, false);
if (!empty($tbroker_gw_name)) {
interface_configure(false, $tbroker_ifkey, false, false);
}
$lan_if = null;
$target_vip = !empty($lan_vip_v4) ? $lan_vip_v4 : $lan_vip_v6;
if (empty($target_vip)) {
return true;
}
foreach ($config['virtualip']['vip'] as $vip) {
if (isset($vip['subnet']) && $vip['subnet'] == $target_vip) {
$lan_if = $vip['interface'];
break;
}
}
if ($lan_if) {
$real_lan_if = get_real_interface($lan_if);
log_failover("Found LAN VIP on interface '{$lan_if}'. Rerouting default gateways.");
if (!empty($lan_vip_v4)) {
$gw_v4 = ['gateway' => $lan_vip_v4, 'if' => $real_lan_if];
system_default_route($gw_v4, 'inet');
}
if (!empty($lan_vip_v6)) {
$gw_v6 = ['gateway' => $lan_vip_v6, 'if' => $real_lan_if];
system_default_route($gw_v6, 'inet6');
}
}
return true;
}
// --- Main Execution ---
register_shutdown_function(function () use ($lock_file) {
if (file_exists($lock_file)) {
unlink($lock_file);
}
});
$lock_handle = fopen($lock_file, 'w');
if ($lock_handle === false || !flock($lock_handle, LOCK_EX | LOCK_NB)) {
exit(1);
}
$subsystem = !empty($argv[1]) ? $argv[1] : '';
$type = !empty($argv[2]) ? $argv[2] : '';
if (!in_array($type, ['MASTER', 'BACKUP'])) {
exit(0);
}
if (!strstr($subsystem, '@')) {
exit(0);
}
$success = false;
if ($type === "MASTER") {
$success = handle_master_transition($ifkey, $subsystem);
} else {
$success = handle_backup_transition($ifkey, $subsystem);
}
if ($success) {
exit(0);
} else {
exit(1);
}
?>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment