Skip to content

Instantly share code, notes, and snippets.

@fabriziosalmi
Last active April 27, 2025 17:25
Show Gist options
  • Save fabriziosalmi/d4e87e417b7cc22edaf0da11696dbd57 to your computer and use it in GitHub Desktop.
Save fabriziosalmi/d4e87e417b7cc22edaf0da11696dbd57 to your computer and use it in GitHub Desktop.
#!/bin/bash
# Script to configure network interfaces for multiple gateways on Ubuntu 24.04+
# ens18 (default): DHCP, default route for general traffic
# ens19 (secondary): Static IP, uses its own gateway ONLY for traffic originating from its IP.
# Enhanced with validation, rollback, policy routing, and improved error handling.
# Strict mode
set -u # Exit on unset variables
set -o pipefail # Exit on pipe failures
# --- Configuration Variables ---
NETPLAN_FILE="/etc/netplan/99-custom-netplan.yaml"
CUSTOM_ROUTE_TABLE_ID="200"
CUSTOM_ROUTING_SERVICE_NAME="custom-routing.service"
CUSTOM_ROUTING_SERVICE_FILE="/etc/systemd/system/${CUSTOM_ROUTING_SERVICE_NAME}"
SCRIPT_PATH="/usr/local/sbin/apply-custom-routing.sh" # Changed path to sbin
LOG_FILE="/var/log/network-config-script.log"
TEST_PING_COUNT=3
RETRY_COUNT=3
RETRY_DELAY=5
DEFAULT_ROUTING_INTERFACE="ens18" # Interface for default route (DHCP)
SECONDARY_ROUTING_INTERFACE="ens19" # Interface for policy-based route (Static)
DEFAULT_TEST_TARGET="8.8.8.8" # Default target for general connectivity test
# Variables set by arguments
ENS19_STATIC_IP_CIDR="" # e.g., 192.168.2.100/24
ENS19_GATEWAY="" # e.g., 192.168.2.1
ENS19_IP_ADDRESS="" # Extracted IP from ENS19_STATIC_IP_CIDR
TEST_TARGET="${DEFAULT_TEST_TARGET}" # Target for testing
# --- Helper Functions ---
# Function to log messages with severity and timestamp
# Usage: msg <type> <message>
# Types: info, warning, error, success
msg() {
local type="$1"
local text="$2"
local color=""
local timestamp
timestamp=$(date "+%Y-%m-%d %H:%M:%S")
case "$type" in
"info") color="\e[34m" ;;
"warning") color="\e[33m" ;;
"error") color="\e[31m" ;;
"success") color="\e[32m" ;;
*) color="\e[0m" ;; # Default
esac
# Log to stderr and append to log file
echo -e "${color}${timestamp} [${type^^}] ${text}\e[0m" | tee -a "$LOG_FILE" >&2
}
# Function to display usage instructions
usage() {
echo "Usage: $0 -s <ens19_static_ip/cidr> -g <ens19_gateway> [-t <test_target>] [-h]"
echo " -s : Static IP address and CIDR prefix for ${SECONDARY_ROUTING_INTERFACE} (e.g., 192.168.2.100/24)"
echo " -g : Gateway IP address for ${SECONDARY_ROUTING_INTERFACE}"
echo " -t : Optional IP address to ping for general connectivity testing (default: ${DEFAULT_TEST_TARGET})"
echo " -h : Display this help message"
exit 1
}
# Check if running as root
check_root() {
if [[ "$EUID" -ne 0 ]]; then
msg "error" "This script must be run as root."
exit 1
fi
# Ensure log file is writable (create if not exists)
touch "$LOG_FILE" || { msg "error" "Cannot write to log file $LOG_FILE"; exit 1; }
chown root:root "$LOG_FILE"
chmod 600 "$LOG_FILE"
msg "info" "Log file initialized: $LOG_FILE"
}
# Check if a command exists
# Usage: check_command <command_name>
check_command() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
msg "error" "Command '$cmd' not found. Please install the package providing it."
# Suggest packages for common commands
case "$cmd" in
"netplan") echo "Try: sudo apt update && sudo apt install netplan.io" >&2 ;;
"ip") echo "Try: sudo apt update && sudo apt install iproute2" >&2 ;;
"ping") echo "Try: sudo apt update && sudo apt install iputils-ping" >&2 ;;
esac
exit 1
fi
}
# Execute a command with retries
# Usage: execute_with_retry "<command>" "<description>" <retry_count> <retry_delay>
execute_with_retry() {
local cmd="$1"
local description="$2"
local retry_count="$3"
local retry_delay="$4"
local attempt=1
local output
local result=1
while [ $attempt -le "$retry_count" ]; do
msg "info" "Attempt $attempt/$retry_count: $description: Running '$cmd'"
# Capture stdout and stderr separately for better logging
output=$(eval "$cmd" 2>&1) # Use eval to handle complex commands/pipelines if needed, be cautious
result=$?
if [ $result -eq 0 ]; then
msg "success" "$description succeeded."
# Log successful output only if verbose logging is desired (optional)
# echo "Output:" >> "$LOG_FILE"
# echo "$output" >> "$LOG_FILE"
return 0
else
msg "warning" "$description failed (Attempt $attempt/$retry_count). Exit code: $result"
echo "Output:" >> "$LOG_FILE"
echo "$output" >> "$LOG_FILE" # Log failure output
if [ $attempt -lt "$retry_count" ]; then
msg "warning" "Retrying in $retry_delay seconds..."
sleep "$retry_delay"
fi
fi
attempt=$((attempt + 1))
done
msg "error" "$description failed after $retry_count attempts."
return 1
}
# Function to roll back changes
rollback() {
msg "warning" "--- Initiating Rollback ---"
local rollback_failed=0
# 1. Stop and disable the custom service
if systemctl is-active --quiet "$CUSTOM_ROUTING_SERVICE_NAME"; then
msg "info" "Stopping custom routing service..."
execute_with_retry "systemctl stop ${CUSTOM_ROUTING_SERVICE_NAME}" "Stop routing service" 2 2 || rollback_failed=1
fi
if systemctl is-enabled --quiet "$CUSTOM_ROUTING_SERVICE_NAME"; then
msg "info" "Disabling custom routing service..."
execute_with_retry "systemctl disable ${CUSTOM_ROUTING_SERVICE_NAME}" "Disable routing service" 2 2 || rollback_failed=1
fi
# 2. Remove the systemd service file
if [ -f "$CUSTOM_ROUTING_SERVICE_FILE" ]; then
msg "info" "Removing systemd service file..."
rm -f "$CUSTOM_ROUTING_SERVICE_FILE" || { msg "warning" "Failed to remove service file $CUSTOM_ROUTING_SERVICE_FILE"; rollback_failed=1; }
execute_with_retry "systemctl daemon-reload" "Reload systemd daemon" 2 2 # Important after removing unit file
fi
# 3. Remove the custom routing script
if [ -f "$SCRIPT_PATH" ]; then
msg "info" "Removing custom routing script..."
rm -f "$SCRIPT_PATH" || { msg "warning" "Failed to remove routing script $SCRIPT_PATH"; rollback_failed=1; }
fi
# 4. Restore Netplan configuration from backup
if [ -f "${NETPLAN_FILE}.bak" ]; then
msg "info" "Restoring Netplan configuration from ${NETPLAN_FILE}.bak..."
if cp -f "${NETPLAN_FILE}.bak" "${NETPLAN_FILE}"; then
msg "success" "Netplan config restored."
# 5. Apply the restored Netplan configuration
msg "info" "Applying restored Netplan configuration..."
if ! execute_with_retry "netplan apply" "Apply restored Netplan config" $RETRY_COUNT $RETRY_DELAY; then
msg "error" "Failed to apply restored Netplan config. Network state may be inconsistent."
rollback_failed=1
else
msg "success" "Restored Netplan config applied."
fi
else
msg "error" "Failed to restore Netplan config from backup."
rollback_failed=1
fi
else
msg "warning" "No Netplan backup file found (${NETPLAN_FILE}.bak). Cannot restore Netplan config."
# Attempt to apply current netplan config just in case it helps
msg "info" "Attempting to re-apply current Netplan configuration..."
execute_with_retry "netplan apply" "Re-apply current Netplan config" 1 0
fi
if [[ $rollback_failed -ne 0 ]]; then
msg "error" "--- Rollback completed with errors. Manual intervention may be required. ---"
exit 1 # Exit with error code after rollback attempt
else
msg "success" "--- Rollback completed successfully. ---"
msg "info" "System state returned to before script execution (best effort)."
msg "info" "You might need to reboot or restart networking ('sudo systemctl restart systemd-networkd') for full effect."
fi
# Important: Don't exit the main script from here if called by trap
# Let the trap handler exit. If called directly, the caller should exit.
}
# Function to test network connectivity
test_connectivity() {
msg "info" "--- Testing Network Connectivity ---"
local test_failed=0
# Test 1: General internet connectivity via default route (expected: ens18)
msg "info" "Testing general connectivity to ${TEST_TARGET} (expected via ${DEFAULT_ROUTING_INTERFACE})..."
if execute_with_retry "ping -c ${TEST_PING_COUNT} -W 2 ${TEST_TARGET}" "Ping test to ${TEST_TARGET}" 2 1; then
msg "success" "General connectivity test to ${TEST_TARGET} PASSED."
else
msg "error" "General connectivity test to ${TEST_TARGET} FAILED."
msg "warning" "Check if ${DEFAULT_ROUTING_INTERFACE} has DHCP lease and default route ('ip route show default')."
test_failed=1
fi
# Test 2: Connectivity via the secondary interface's specific route
# We ping the gateway associated with ens19 using ens19's IP as the source.
if [ -z "$ENS19_IP_ADDRESS" ]; then
msg "error" "Cannot run secondary interface test: ENS19 IP address is not set."
test_failed=1
elif [ -z "$ENS19_GATEWAY" ]; then
msg "error" "Cannot run secondary interface test: ENS19 Gateway is not set."
test_failed=1
else
msg "info" "Testing connectivity to ${ENS19_GATEWAY} via ${SECONDARY_ROUTING_INTERFACE} (Source IP: ${ENS19_IP_ADDRESS})..."
# Ensure the interface is up before trying to ping from it
if ! ip link show dev "$SECONDARY_ROUTING_INTERFACE" up > /dev/null; then
msg "warning" "Interface ${SECONDARY_ROUTING_INTERFACE} is down. Attempting to bring it up..."
ip link set dev "$SECONDARY_ROUTING_INTERFACE" up
sleep 2 # Give it a moment
if ! ip link show dev "$SECONDARY_ROUTING_INTERFACE" up > /dev/null; then
msg "error" "Failed to bring up interface ${SECONDARY_ROUTING_INTERFACE}. Skipping test."
test_failed=1
fi
fi
# Proceed with ping test if interface is up
if ip link show dev "$SECONDARY_ROUTING_INTERFACE" up > /dev/null; then
# Use -I flag to specify source IP/Interface
if execute_with_retry "ping -c ${TEST_PING_COUNT} -W 2 -I ${ENS19_IP_ADDRESS} ${ENS19_GATEWAY}" "Ping test from ${ENS19_IP_ADDRESS} to ${ENS19_GATEWAY}" 2 1; then
msg "success" "Secondary path connectivity test (Source ${ENS19_IP_ADDRESS} -> Gateway ${ENS19_GATEWAY}) PASSED."
else
msg "error" "Secondary path connectivity test (Source ${ENS19_IP_ADDRESS} -> Gateway ${ENS19_GATEWAY}) FAILED."
msg "warning" "Check policy routing ('ip rule show', 'ip route show table ${CUSTOM_ROUTE_TABLE_ID}') and firewall rules."
test_failed=1
fi
fi
fi
if [[ $test_failed -ne 0 ]]; then
msg "error" "--- Network Connectivity Test FAILED ---"
return 1
else
msg "success" "--- Network Connectivity Test PASSED ---"
return 0
fi
}
# Check if specified network interfaces exist
check_interfaces() {
msg "info" "Checking if interfaces ${DEFAULT_ROUTING_INTERFACE} and ${SECONDARY_ROUTING_INTERFACE} exist..."
local iface
for iface in "$DEFAULT_ROUTING_INTERFACE" "$SECONDARY_ROUTING_INTERFACE"; do
if ! ip link show "$iface" > /dev/null 2>&1; then
msg "error" "Network interface '$iface' not found. Please verify interface names using 'ip link show'."
exit 1
fi
done
msg "success" "Required interfaces found."
}
# --- Main Script ---
# Setup trap for unexpected errors - triggers rollback
trap 'msg "error" "An unexpected error occurred. Initiating rollback..."; rollback; exit 1' ERR
# 0. Initial Checks
check_root
check_command "netplan"
check_command "ip"
check_command "systemctl"
check_command "ping" # Make sure ping is available
# 1. Parse Arguments
msg "info" "Parsing command line arguments..."
while getopts "s:g:t:h" opt; do
case "$opt" in
s) ENS19_STATIC_IP_CIDR="$OPTARG" ;;
g) ENS19_GATEWAY="$OPTARG" ;;
t) TEST_TARGET="$OPTARG" ;;
h) usage ;;
\?) msg "error" "Invalid option: -$OPTARG" >&2; usage ;;
:) msg "error" "Option -$OPTARG requires an argument." >&2; usage ;;
esac
done
shift $((OPTIND -1))
# Check for required arguments
if [ -z "$ENS19_STATIC_IP_CIDR" ] || [ -z "$ENS19_GATEWAY" ]; then
msg "error" "Missing required arguments: -s and -g are mandatory."
usage
fi
# Validate and extract IP from CIDR
if [[ ! "$ENS19_STATIC_IP_CIDR" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
msg "error" "Invalid format for static IP/CIDR (-s). Expected format: xxx.xxx.xxx.xxx/yy"
exit 1
fi
ENS19_IP_ADDRESS=$(echo "$ENS19_STATIC_IP_CIDR" | cut -d'/' -f1)
# Validate Gateway IP format (basic)
if [[ ! "$ENS19_GATEWAY" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
msg "error" "Invalid format for gateway IP (-g). Expected format: xxx.xxx.xxx.xxx"
exit 1
fi
# Validate Interfaces
check_interfaces
msg "info" "--- Starting Network Configuration ---"
msg "info" "Configuration Parameters:"
msg "info" " Default Interface (DHCP): ${DEFAULT_ROUTING_INTERFACE}"
msg "info" " Secondary Interface (Static): ${SECONDARY_ROUTING_INTERFACE}"
msg "info" " Static IP/CIDR: ${ENS19_STATIC_IP_CIDR}"
msg "info" " Extracted IP: ${ENS19_IP_ADDRESS}"
msg "info" " Gateway: ${ENS19_GATEWAY}"
msg "info" " Connectivity Test Target: ${TEST_TARGET}"
msg "info" " Custom Routing Table ID: ${CUSTOM_ROUTE_TABLE_ID}"
msg "info" " Log File: ${LOG_FILE}"
# 2. Backup Existing Netplan Configuration
msg "info" "Backing up current Netplan configuration to ${NETPLAN_FILE}.bak..."
if ! cp -f "${NETPLAN_FILE}" "${NETPLAN_FILE}.bak"; then
msg "warning" "Failed to create Netplan backup. Proceeding without backup."
# Don't exit, allow proceeding but warn the user
fi
# 3. Create New Netplan Configuration
msg "info" "Generating new Netplan configuration file: ${NETPLAN_FILE}"
cat > "$NETPLAN_FILE" <<EOF
# Configuration generated by script $(basename "$0") on $(date)
network:
version: 2
renderer: networkd
ethernets:
${DEFAULT_ROUTING_INTERFACE}:
dhcp4: true
dhcp4-overrides:
use-routes: true # Ensure DHCP supplies a default route
${SECONDARY_ROUTING_INTERFACE}:
dhcp4: false
addresses:
- ${ENS19_STATIC_IP_CIDR}
# NO gateway4 here - handled by policy routing script
# Tell netplan not to add a default route for this interface
routes:
- to: 0.0.0.0/0
via: 0.0.0.0 # Invalid gateway forces no default route creation
metric: 200 # Higher metric just in case, but scope link is key
scope: link
# Optional: Add specific routes needed for this interface if any
# routes:
# - to: 10.10.10.0/24
# via: ${ENS19_GATEWAY}
EOF
if [ $? -ne 0 ]; then
msg "error" "Failed to write Netplan configuration to ${NETPLAN_FILE}."
rollback # Attempt rollback
exit 1
fi
msg "success" "Netplan configuration file created."
# 4. Create the Custom Routing Script
msg "info" "Creating custom routing script: ${SCRIPT_PATH}"
cat > "$SCRIPT_PATH" <<EOF
#!/bin/bash
# This script applies policy-based routing rules for ${SECONDARY_ROUTING_INTERFACE}.
# Generated by $(basename "$0") on $(date)
# Use logger for messages within this script
log_msg() {
logger -t custom-routing "\$1"
}
log_msg "Applying custom routing rules..."
# Ensure the custom table exists, flush if it does
ip route flush table ${CUSTOM_ROUTE_TABLE_ID} || log_msg "Table ${CUSTOM_ROUTE_TABLE_ID} might not exist yet, ignoring flush error."
# Add the default route for the secondary interface to the custom table
# Use 'replace' to ensure it's updated if the script runs again
ip route replace default via ${ENS19_GATEWAY} dev ${SECONDARY_ROUTING_INTERFACE} table ${CUSTOM_ROUTE_TABLE_ID}
if [ \$? -ne 0 ]; then
log_msg "ERROR: Failed to add route to table ${CUSTOM_ROUTE_TABLE_ID} via ${ENS19_GATEWAY} dev ${SECONDARY_ROUTING_INTERFACE}"
exit 1
fi
log_msg "Added default route to table ${CUSTOM_ROUTE_TABLE_ID} via ${ENS19_GATEWAY} dev ${SECONDARY_ROUTING_INTERFACE}"
# Add the rule to use the custom table for traffic *from* the secondary interface's IP
# Use a priority lower than the default 'main' table rule (usually 32766)
RULE_PRIORITY=10000
# Delete existing rule first (if any) to avoid duplicates
ip rule del from ${ENS19_IP_ADDRESS}/32 lookup ${CUSTOM_ROUTE_TABLE_ID} prio ${RULE_PRIORITY} 2>/dev/null
ip rule add from ${ENS19_IP_ADDRESS}/32 lookup ${CUSTOM_ROUTE_TABLE_ID} prio ${RULE_PRIORITY}
if [ \$? -ne 0 ]; then
log_msg "ERROR: Failed to add rule 'from ${ENS19_IP_ADDRESS}/32 table ${CUSTOM_ROUTE_TABLE_ID} prio ${RULE_PRIORITY}'"
# Attempt cleanup of route table entry on rule failure
ip route flush table ${CUSTOM_ROUTE_TABLE_ID}
exit 1
fi
log_msg "Added rule: from ${ENS19_IP_ADDRESS}/32 lookup ${CUSTOM_ROUTE_TABLE_ID} prio ${RULE_PRIORITY}"
# Ensure the main default route (via ens18/DHCP) is present. Netplan should handle this.
# We log a warning if it's missing, but don't try to fix it here.
if ! ip route show default | grep -q "dev ${DEFAULT_ROUTING_INTERFACE}"; then
log_msg "WARNING: Default route via ${DEFAULT_ROUTING_INTERFACE} not found. Check DHCP configuration for ${DEFAULT_ROUTING_INTERFACE}."
fi
log_msg "Custom routing rules applied successfully."
exit 0
EOF
if [ $? -ne 0 ]; then
msg "error" "Failed to write custom routing script to ${SCRIPT_PATH}."
rollback # Attempt rollback
exit 1
fi
chmod +x "$SCRIPT_PATH"
if [ $? -ne 0 ]; then
msg "error" "Failed to make routing script ${SCRIPT_PATH} executable."
rollback # Attempt rollback
exit 1
fi
msg "success" "Custom routing script created and made executable."
# 5. Create the systemd Service File
msg "info" "Creating systemd service file: ${CUSTOM_ROUTING_SERVICE_FILE}"
cat > "$CUSTOM_ROUTING_SERVICE_FILE" <<EOF
# Service file generated by $(basename "$0") on $(date)
[Unit]
Description=Apply Custom Policy-Based Routing for ${SECONDARY_ROUTING_INTERFACE}
After=network-online.target
Wants=network-online.target
# If problems arise with network-online.target, consider:
# After=systemd-networkd.service NetworkManager.service
# Wants=systemd-networkd.service NetworkManager.service
[Service]
Type=oneshot
ExecStart=${SCRIPT_PATH}
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
if [ $? -ne 0 ]; then
msg "error" "Failed to create systemd service file ${CUSTOM_ROUTING_SERVICE_FILE}."
rollback # Attempt rollback
exit 1
fi
msg "success" "Systemd service file created."
# 6. Reload systemd, Enable and Start the Service
msg "info" "Reloading systemd daemon, enabling and starting custom routing service..."
execute_with_retry "systemctl daemon-reload" "Reload systemd daemon" $RETRY_COUNT $RETRY_DELAY || { rollback; exit 1; }
execute_with_retry "systemctl enable ${CUSTOM_ROUTING_SERVICE_NAME}" "Enable routing service" $RETRY_COUNT $RETRY_DELAY || { rollback; exit 1; }
# Don't start yet, apply Netplan first to ensure interfaces are configured
# 7. Apply Netplan Configuration
msg "info" "Applying Netplan configuration..."
# Use netplan generate first to catch syntax errors early
msg "info" "Running 'netplan generate' to check syntax..."
if ! sudo netplan generate; then
msg "error" "Netplan configuration syntax error. Check ${NETPLAN_FILE}."
# Attempt to show the error details from journalctl
msg "info" "Checking journalctl for netplan errors (may need scrolling up):"
journalctl -n 50 | grep -i netplan || true
rollback
exit 1
fi
msg "success" "Netplan configuration syntax check passed."
msg "info" "Running 'netplan apply'..."
if ! execute_with_retry "netplan apply" "Apply Netplan configuration" $RETRY_COUNT $RETRY_DELAY; then
msg "error" "Failed to apply Netplan configuration. Check logs (journalctl -u systemd-networkd)."
rollback
exit 1
fi
msg "success" "Netplan configuration applied."
msg "info" "Waiting a few seconds for interfaces to potentially settle..."
sleep 5
# 8. Start the Custom Routing Service (after Netplan apply)
msg "info" "Starting custom routing service..."
if ! execute_with_retry "systemctl restart ${CUSTOM_ROUTING_SERVICE_NAME}" "Start/Restart routing service" $RETRY_COUNT $RETRY_DELAY; then
msg "error" "Failed to start custom routing service ${CUSTOM_ROUTING_SERVICE_NAME}."
msg "info" "Check service status: systemctl status ${CUSTOM_ROUTING_SERVICE_NAME}"
msg "info" "Check script logs: journalctl -u ${CUSTOM_ROUTING_SERVICE_NAME} or logger output in /var/log/syslog"
rollback
exit 1
fi
msg "success" "Custom routing service started."
# 9. Final Validation
msg "info" "Running final network connectivity tests..."
if ! test_connectivity; then
msg "error" "Network configuration validation failed after applying changes."
msg "error" "Initiating rollback due to failed validation."
rollback
exit 1
fi
# 10. Success!
msg "success" "--- Network Configuration Successfully Applied and Validated! ---"
msg "info" "Summary of changes:"
msg "info" " - Netplan configuration updated: ${NETPLAN_FILE}"
msg "info" " - Custom routing script installed: ${SCRIPT_PATH}"
msg "info" " - Systemd service enabled and started: ${CUSTOM_ROUTING_SERVICE_NAME}"
msg "info" "Policy routing rules should now be active."
msg "info" "Verify with: 'ip rule show' and 'ip route show table ${CUSTOM_ROUTE_TABLE_ID}'"
msg "info" "Configuration is designed to persist across reboots."
msg "info" "To revert changes manually, run 'sudo ${0} --rollback' (hypothetical future feature) or manually reverse steps/restore backup."
# Disable trap before exiting normally
trap - ERR
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment