Automating Aruba Instant On 1930 Switch Backups (So Joe Can Have a Life)
๐ฅ The Team
In my team of three—composed of two senior engineers (myself included) and one junior—Joe Biden is the most junior member. No, not the President. Our Joe Biden is fresh out of college, eager to learn, and unfortunately for him, he got assigned one of the most painfully manual tasks in our Preventive Maintenance Program:
Backing up switch configurations. Quarterly. For over 100 switches. Manually. Through a browser.
Let me paint you a picture.
๐ The Network Environment
We manage a Layer 3 network where the edge layer consists of over 100+ Aruba Instant On 1930 switches. These are solid entry-level switches, but their management interface leaves much to be desired when it comes to automation:
- ❌ No SSH
- ❌ No real API
- ✅ Just a Web GUI
So, every quarter, Joe would spend days logging into each switch's web interface, navigating to the config page, and downloading the current config manually. Multiply that by 100+ switches and… yeah. Pain.
๐ง The Programmer's Philosophy
As the Head of the Department (and the programmer who takes care of these amazing folks), I live by the motto:
“I choose a lazy person to do a hard job, because a lazy person will find an easy way to do it.” — Bill Gates
...and I also keep in mind the timeless advice:
“Sharpen the axe before chopping the tree.”
For me, automation wasn’t just a nice-to-have—it was the smartest path forward. As the one leading the team and making sure these guys aren’t bogged down by tedious tasks, I knew that creating this script would free us up to do more important work and work smarter, not harder.
๐ In This Post, We Discuss
- Reverse Engineering the Web GUI
- Open-Source Clues
- Our Python Automation
- Why This Script Matters
- Wrap-Up: The Power of Automation
๐ Step 1: Reverse Engineering the Web GUI
We started by reverse engineering the manual process. With browser dev tools open and Joe clicking through the config backup steps, we mapped out the key HTTP requests.
๐งพ Observed HTTP Requests
1. Encryption Settings Request
curl 'http://192.168.53.1/device/wcd?{EncryptionSetting}' \
-H 'Accept: application/xml, text/xml, */*; q=0.01' \
-H 'Cookie: sessionID=UserId=...&; userName=admin' \
--insecure
2. Encrypted Login Request
curl 'http://192.168.53.1/.../system.xml?action=login&cred=<long_encrypted_string>' \
-H 'Referer: http://192.168.53.1/.../login.htm' \
-H 'Cookie: sessionID=UserId=...&; userName=admin' \
--insecure
3. User Authentication
curl 'http://192.168.53.1/.../authenticate_user.xml' \
-H 'Referer: http://192.168.53.1/.../home.htm' \
-H 'Cookie: sessionID=UserId=...&; userName=admin' \
--insecure
4. Finally, the Config Download
curl 'http://192.168.53.1/.../http_download?action=2&ssd=4' \
-H 'Referer: http://192.168.53.1/.../home.htm' \
-H 'Cookie: sessionID=UserId=...&; userName=admin' \
--insecure
(Cookies and user info have been masked for security.)
๐งญ Step 2: Open-Source Clues
We stumbled upon this Perl script on GitHub by @dkolasinski, a fellow engineer with the same frustrations. His work confirmed our findings and gave us a great reference point.
๐ Step 3: Our Python Automation
This Python script has been tested against Aruba switches with firmware 2.6.x and above. It's designed to back up configurations efficiently while handling a few key challenges, including:
- Encrypted logins for secure authentication
- Dynamic web interfaces, adapting to any changes or updates
- Session token management, ensuring consistent access even with timeout issues
- Parallel processing for scalability, handling multiple switches at once
While this script offers a reliable solution, keep in mind it is tailored for Aruba switches, and its functionality may be limited for switches running on other firmware versions or different network devices.
๐ฆ Full Source Code & Project Repository: ArubaBackup
๐️ Script Directory Structure
The project is organized in a simple directory structure:
main.py
: The core script that performs the backups.inventory.json
: Contains the list of switches (IP, username, password) to back up.
To get started, install the necessary dependencies by running:
pip install requests pycryptodome colorama
๐งณ Dependencies
These libraries power the script’s core functionality:
requests
Handles all HTTP requests and responses to interact with the switch’s web UI. Enables login, config download, and session management.pycryptodome
Used to encrypt login credentials using Aruba’s RSA public key. Without this, login requests to modern Aruba switches will fail.colorama
Adds color-coded log output to the terminal, making it easier to track events, errors, and progress in real-time.asyncio
+concurrent.futures
(standard library)
Enables non-blocking, concurrent backups of multiple switches. Boosts performance without added complexity.
๐ The Source Code main.py
#!/usr/bin/env python3
import sys
import re
import requests
import xml.etree.ElementTree as ET
from urllib.parse import quote
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
import binascii
import warnings
import logging
import os
import json
import asyncio
import concurrent.futures
import time
from colorama import init, Fore, Style
# Initialize colorama for cross-platform color support
init()
# Custom formatter for colored logs
class ColoredFormatter(logging.Formatter):
COLOR_MAP = {
'ERROR': Fore.RED,
'WARNING': Fore.YELLOW,
'INFO': Fore.GREEN,
'DEBUG': Fore.BLUE
}
def format(self, record):
color = self.COLOR_MAP.get(record.levelname, '')
message = super().format(record)
return f"{color}{message}{Style.RESET_ALL}"
# Configure logging
logger = logging.getLogger(__name__)
handler = logging.StreamHandler()
handler.setFormatter(ColoredFormatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
# Suppress SSL warnings (not needed for HTTP)
warnings.filterwarnings("ignore", category=requests.packages.urllib3.exceptions.InsecureRequestWarning)
def retry_request(func, max_attempts=3, initial_delay=2, backoff_factor=2):
"""Retry a requests function on transient failures."""
def wrapper(*args, **kwargs):
attempt = 1
delay = initial_delay
while attempt <= max_attempts:
try:
return func(*args, **kwargs)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as e:
host = kwargs.get('host', args[1] if len(args) > 1 else 'unknown')
url = kwargs.get('url', args[0] if len(args) > 0 else 'unknown')
if attempt == max_attempts:
logger.error(f"Switch {host}: Request to {url} failed after {max_attempts} attempts: {e}")
raise
logger.warning(f"Switch {host}: Retrying request to {url} (attempt {attempt}/{max_attempts}): {e}")
time.sleep(delay)
attempt += 1
delay *= backoff_factor
return None # Should never reach here
return wrapper
def validate_input(host):
"""Validate the hostname or IP address."""
if not re.match(r'^[0-9a-zA-Z\.\-]+$', host):
logger.error(f"Switch {host}: IP/Hostname - input does not match pattern!")
raise ValueError("Invalid IP/Hostname")
return host
def initialize_session(host, username):
"""Initialize a requests session with cookies."""
session = requests.Session()
cookies = {
'activeLangId': 'english',
'firstWelcomeBanner': 'true',
'userName': username
}
for key, value in cookies.items():
session.cookies.set(key, value, domain=host, path='/')
return session
@retry_request
def get_document_root(session, host):
"""Step 1: Get document root from initial redirect."""
url = f"http://{host}"
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/135.0.0.0'}
logger.info(f"Switch {host}: Requesting {url}")
response = session.get(url, headers=headers, allow_redirects=False, timeout=10)
response.raise_for_status()
logger.debug(f"Switch {host}: Status: {response.status_code}, Headers: {response.headers}")
if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("Location", "")
logger.debug(f"Switch {host}: Location: {location}")
match = re.match(r'^/([^/]+)/', location)
if match:
document_root = match.group(1)
logger.info(f"Switch {host}: req 1. LOCATION REQ OK")
return document_root, location
else:
logger.error(f"Switch {host}: req 1. LOCATION REQ OK, BUT CANNOT PARSE LOCATION STRING: {location}")
raise ValueError("Cannot parse location string")
else:
logger.error(f"Switch {host}: req 1. LOCATION HEADER EXPECTED BUT NOT FOUND: {response.status_code}")
raise ValueError("Location header not found")
@retry_request
def check_login_page(session, host, initial_location):
"""Step 2: Access login page to confirm Aruba device."""
url = f"http://{host}{initial_location}"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/135.0.0.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': f'http://{host}{initial_location}'
}
logger.info(f"Switch {host}: Requesting login page: {url}")
response = session.get(url, headers=headers, timeout=10)
response.raise_for_status()
logger.debug(f"Switch {host}: Status: {response.status_code}, Headers: {response.headers}")
if response.status_code == 200:
content = response.text
logger.debug(f"Switch {host}: Response content (first 500 chars): {content[:500]}")
if "inputUsername" in content:
logger.info(f"Switch {host}: req 2. INITIAL REQ OK: ARUBA INSTANT ON DETECTED")
else:
logger.error(f"Switch {host}: req 2. INITIAL REQ OK, BUT ARUBA LOGIN FIELD 'inputUsername' NOT FOUND: {content[:500]}")
raise ValueError("Aruba login field not found")
else:
logger.error(f"Switch {host}: req 2. INITIAL REQ ERROR: {response.status_code} {response.text}")
raise ValueError("Login page request failed")
@retry_request
def get_encryption_settings(session, host, document_root):
"""Step 3: Retrieve RSA public key, login token, and encryption settings."""
url = f"http://{host}/device/wcd?{{EncryptionSetting}}"
headers = {
'Accept': 'application/xml, text/xml, */*; q=0.01',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/135.0.0.0',
'X-Requested-With': 'XMLHttpRequest',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': f'http://{host}/{document_root}/hpe/config/login.htm'
}
logger.info(f"Switch {host}: Requesting encryption settings: {url}")
response = session.get(url, headers=headers, timeout=10)
response.raise_for_status()
logger.debug(f"Switch {host}: Status: {response.status_code}, Headers: {response.headers}")
logger.debug(f"Switch {host}: Cookies: {session.cookies.get_dict()}")
if response.status_code == 200:
content = response.text
logger.debug(f"Switch {host}: Encryption settings response: {content}")
rsa_key = re.search(r'<rsaPublicKey>(.+)</rsaPublicKey>', content, re.DOTALL)
login_token = re.search(r'<loginToken>(.+)</loginToken>', content, re.DOTALL)
encrypt_enable = re.search(r'<passwEncryptEnable>(.+)</passwEncryptEnable>', content, re.DOTALL)
if not rsa_key:
logger.error(f"Switch {host}: req 3. RSA KEY REQ OK, BUT NO RSA KEY FOUND: {content}")
raise ValueError("RSA key not found")
if not login_token:
logger.error(f"Switch {host}: req 3. LOGIN TOKEN OK, BUT NO LOGIN TOKEN FOUND: {content}")
raise ValueError("Login token not found")
if not encrypt_enable:
logger.error(f"Switch {host}: req 3. PASSWORD ENCRYPT ENABLE REQ OK, BUT NO PASSWORD ENCRYPT ENABLE FOUND: {content}")
raise ValueError("Encrypt enable not found")
logger.info(f"Switch {host}: req 3. RSA KEY REQ OK")
logger.info(f"Switch {host}: req 3. LOGIN TOKEN REQ OK")
logger.info(f"Switch {host}: req 3. PASSWORD ENCRYPT ENABLE REQ OK")
return rsa_key.group(1), login_token.group(1), encrypt_enable.group(1)
else:
logger.error(f"Switch {host}: req 3. INITIAL ENCRYPTION REQ ERROR: {response.status_code} {response.text}")
raise ValueError("Encryption settings request failed")
def encrypt_credentials(rsa_key, login_token, username, password, passw_encrypt_enable):
"""Encrypt login credentials using RSA if required."""
login_string = f"user={quote(username)}&password={quote(password)}&ssd=true&token={login_token}&"
if passw_encrypt_enable == '1':
key = RSA.import_key(rsa_key)
cipher = PKCS1_v1_5.new(key)
encrypted = cipher.encrypt(login_string.encode())
hex_encrypted = binascii.hexlify(encrypted).decode()
return hex_encrypted
else:
return login_string
@retry_request
def login(session, host, document_root, hex_encrypted):
"""Step 4: Perform login."""
url = f"http://{host}/{document_root}/hpe/config/system.xml?action=login&cred={hex_encrypted}"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/135.0.0.0',
'Accept': '*/*',
'Referer': f'http://{host}/{document_root}/hpe/config/login.htm',
'Accept-Language': 'en-US,en;q=0.9'
}
logger.info(f"Switch {host}: Requesting login: {url}")
response = session.get(url, headers=headers, timeout=10)
response.raise_for_status()
logger.debug(f"Switch {host}: Status: {response.status_code}, Headers: {response.headers}")
if response.status_code == 200:
status = re.search(r'<statusString>(.+)</statusString>', response.text, re.DOTALL)
if status and status.group(1) == 'OK':
logger.info(f"Switch {host}: req 4. LOGIN OK")
else:
logger.error(f"Switch {host}: req 4. LOGIN FAILED, RESPONSE: {status.group(1) if status else response.text}")
raise ValueError("Login failed")
else:
logger.error(f"Switch {host}: req 4. LOGIN REQUEST ERROR: {response.status_code} {response.text}")
raise ValueError("Login request failed")
@retry_request
def authenticate_user(session, host, document_root):
"""Step 5: Authenticate user session."""
url = f"http://{host}/{document_root}/hpe/device/authenticate_user.xml"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/135.0.0.0',
'Accept': '*/*',
'Referer': f'http://{host}/{document_root}/hpe/home.htm',
'Accept-Language': 'en-US,en;q=0.9'
}
logger.info(f"Switch {host}: Requesting user authentication: {url}")
response = session.get(url, headers=headers, timeout=10)
response.raise_for_status()
logger.debug(f"Switch {host}: Status: {response.status_code}, Headers: {response.headers}")
if response.status_code == 200:
logger.info(f"Switch {host}: req 5. USER AUTHENTICATION OK")
else:
logger.error(f"Switch {host}: req 5. USER AUTHENTICATION ERROR: {response.status_code} {response.text}")
raise ValueError("Authentication failed")
@retry_request
def download_config(session, host, document_root, backup_dir):
"""Step 6: Download the running configuration and save to backup directory."""
url = f"http://{host}/{document_root}/hpe/http_download?action=2&ssd=4"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/135.0.0.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Upgrade-Insecure-Requests': '1',
'Referer': f'http://{host}/{document_root}/hpe/home.htm',
'Accept-Language': 'en-US,en;q=0.9'
}
logger.info(f"Switch {host}: Requesting config download: {url}")
response = session.get(url, headers=headers, timeout=10)
response.raise_for_status()
logger.debug(f"Switch {host}: Status: {response.status_code}, Headers: {response.headers}")
if response.status_code == 200:
logger.info(f"Switch {host}: req 6. DOWNLOAD OK")
content = response.text
hostname = None
try:
root = ET.fromstring(content)
hostname_elem = root.find(".//hostname")
if hostname_elem is not None and hostname_elem.text:
hostname = re.sub(r'[^\w\-]', '_', hostname_elem.text.strip())
except ET.ParseError as e:
logger.warning(f"Switch {host}: Failed to parse config as XML: {e}. Response (first 500 chars): {content[:500]!r}")
if not hostname:
hostname_match = re.search(r'^\s*hostname\s+([^\s]+)', content, re.MULTILINE | re.IGNORECASE)
if hostname_match:
hostname = re.sub(r'[^\w\-]', '_', hostname_match.group(1).strip())
if hostname:
filename = f"{hostname}.cfg"
else:
logger.warning(f"Switch {host}: Hostname not found in config. Using fallback filename.")
logger.debug(f"Switch {host}: Config content (first 500 chars): {content[:500]!r}")
filename = f"switch_{host}.cfg"
# Save to backup directory
file_path = os.path.join(backup_dir, filename)
try:
with open(file_path, 'w') as f:
f.write(content)
logger.info(f"Switch {host}: Config saved as: {file_path}")
except OSError as e:
logger.error(f"Switch {host}: Failed to save config to {file_path}: {e}")
raise ValueError(f"Failed to save config: {e}")
else:
logger.error(f"Switch {host}: req 6. DOWNLOAD ERROR: {response.status_code} {response.text}")
raise ValueError("Download failed")
async def backup_switch(switch, loop, backup_dir):
"""Backup a single switch asynchronously."""
host = switch['ip']
username = switch['username']
password = switch['password']
try:
# Validate input
host = await loop.run_in_executor(None, validate_input, host)
# Initialize session
session = await loop.run_in_executor(None, initialize_session, host, username)
# Step 1: Get document root
document_root, initial_location = await loop.run_in_executor(None, get_document_root, session, host)
# Step 2: Check login page
await loop.run_in_executor(None, check_login_page, session, host, initial_location)
# Step 3: Get encryption settings
rsa_public_key, login_token, passw_encrypt_enable = await loop.run_in_executor(None, get_encryption_settings, session, host, document_root)
# Step 4: Encrypt credentials and login
hex_encrypted = await loop.run_in_executor(None, encrypt_credentials, rsa_public_key, login_token, username, password, passw_encrypt_enable)
await loop.run_in_executor(None, login, session, host, document_root, hex_encrypted)
# Step 5: Authenticate user
await loop.run_in_executor(None, authenticate_user, session, host, document_root)
# Step 6: Download configuration
await loop.run_in_executor(None, download_config, session, host, document_root, backup_dir)
return host, True, None
except Exception as e:
logger.error(f"Switch {host}: Backup failed: {str(e)}")
return host, False, str(e)
async def main():
"""Load switches from inventory.json and backup concurrently."""
# Create backup directory
script_dir = os.path.dirname(os.path.abspath(__file__))
backup_dir = os.path.join(script_dir, 'backup')
try:
os.makedirs(backup_dir, exist_ok=True)
logger.info(f"Backup directory ready: {backup_dir}")
except OSError as e:
logger.error(f"Failed to create backup directory {backup_dir}: {e}")
return
# Load inventory.json
inventory_path = os.path.join(script_dir, 'inventory.json')
try:
with open(inventory_path, 'r') as f:
inventory = json.load(f)
switches = inventory.get('switches', [])
if not switches:
logger.error("No switches found in inventory.json")
return
except FileNotFoundError:
logger.error(f"inventory.json not found at {inventory_path}")
return
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in inventory.json: {e}")
return
# Validate switch entries
valid_switches = []
for switch in switches:
if not all(key in switch for key in ('ip', 'username', 'password')) or not all(switch[key] for key in ('ip', 'username', 'password')):
logger.error(f"Invalid switch entry: {switch}")
else:
valid_switches.append(switch)
if not valid_switches:
logger.error("No valid switches to process")
return
logger.info(f"Processing {len(valid_switches)} switches")
# Run backups concurrently
loop = asyncio.get_running_loop()
tasks = [backup_switch(switch, loop, backup_dir) for switch in valid_switches]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Summarize results
successes = []
failures = []
for host, success, error in results:
if success:
successes.append(host)
else:
failures.append((host, error))
logger.info(f"Backup completed. Successes: {len(successes)}/{len(valid_switches)}, Failures: {len(failures)}")
if successes:
logger.info(f"Successful switches: {', '.join(successes)}")
if failures:
logger.error(f"Failed switches: {', '.join(f'{host} ({error})' for host, error in failures)}")
if __name__ == "__main__":
asyncio.run(main())
๐บ Step-by-Step Flow: The Backup Dance
Here’s how the backup magic works, step by step:
- Get Root Document
→ Accesshttp://<ip>
to identify Aruba’s login path. - Verify Login Page
→ Look for Aruba-specific tags likeinputUsername
. - Fetch Encryption Parameters
→ Parse RSA public key and session tokens from the HTML page. - Encrypt Credentials
→ Encryptusername:password
using the public key. - Send Login Request
→ POST to the login endpoint with the encrypted payload. - Authenticate
→ Send a request to/authenticate_user.xml
to finalize session. - Download Configuration
→ GET/http_download?action=2&ssd=4
to fetch config. - Save to File
→ Store asbackup/<hostname>.cfg
or fallback filename.
๐ Every step includes retry logic and logging, and the entire sequence is run concurrently for multiple switches.
๐งพ JSON Inventory
The starting point is a simple JSON file listing all our Aruba instant one switches called inventory.json
:
{
"switches": [
{"ip": "192.168.1.1", "username": "admin", "password": "*****"},
...
]
}
Each entry contains:
- IP address
- Username
- Password (either plaintext or pulled from a vault or secret manager)
⚡ Asynchronous Backups
Thanks to Python’s concurrency features, the script uses asyncio
and run_in_executor
to back up multiple switches in parallel.
What it means:
Instead of backing up switches one by one, it handles all of them simultaneously — without the complexity of threading.
๐ Retry Resilience
Even in enterprise networks, timeouts and hiccups happen. That’s why our script has built-in retry logic to ensure it keeps running smoothly even when things don’t go as planned.
Built-in Retry Logic:
- Max Attempts: 3
- Exponential Backoff:
2s → 4s → 8s between retries - Error Handling:
Retries onTimeout
,ConnectionError
,ValueError
, and other network issues.
๐ง Smart Filenames
To keep backups traceable and human-friendly, the script:
- Parses the downloaded config to extract the hostname using regex.
- Falls back to IP-based naming if hostname isn’t found.
For example:
backup/switch-core.cfg
backup/switch-floor1.cfg
backup/switch_192.168.1.5.cfg
๐ Organized Directory
All configurations are saved inside a structured backup/
folder. This ensures that even large networks have backups that are:
- Easy to navigate
- Timestamp-independent
- Ready for automation (like Git or remote sync)
backup/
├── switch-core.cfg
├── switch-floor1.cfg
└── switch_192.168.1.5.cfg
๐ Log Coloring
Color-coded terminal output makes real-time monitoring effortless:
- ✅ INFO → Green
- ⚠️ WARNING → Yellow
- ❌ ERROR → Red
- ๐ DEBUG → Blue
Powered by colorama
, these make logs scannable at a glance — especially useful during bulk backups.
๐ฏ Aruba-Specific Logic
Aruba switches don’t follow standard login conventions — they use a mix of RSA encryption, cookie-based sessions, and dynamic redirect logic. This script handles those quirks flawlessly:
Aruba Oddities:
- RSA-Encrypted Logins:
Aruba requires username and password to be encrypted with a public key fetched from its login page. - Cookie Sessions & Tokens:
Aruba’s login requires specific fields likeuserName
, and the session relies on cookies and hidden tokens. - Redirects & Dynamic Paths:
Unlike static routers, Aruba’s login paths vary and require redirect handling.
☕ Why This Script Matters
Before this automation, backing up Aruba switches meant:
- Painfully slow manual work: Logging into each switch’s web interface and manually downloading configurations. This could take 2–3 days for over 100 switches.
- Dealing with unreliable interfaces: The Web GUI was prone to JavaScript errors and timeouts, and there were no bulk export options available.
- Inefficient use of time: Joe, the junior engineer, spent days on this manual task, leaving little time for more meaningful work.
Now?
- Instantaneous backups: With the script, the entire backup process now takes just 5 minutes for over 100 switches.
- Audit compliance: The automated process ensures that backups are regularly performed and consistently meet audit requirements.
- More time for meaningful tasks: Joe can now focus on more valuable work, and even better—he learned a ton about automation along the way!
This script has streamlined a previously painful and tedious process into a smooth, reliable, and fast operation. Whether managing a handful of switches or hundreds, this solution scales efficiently and gives teams more time to focus on impactful work.
๐ง๐ป Wrap-Up: The Power of Automation
And that’s a wrap, folks! ๐ We’ve taken a chore that was eating up hours (hello, manual backups!) and turned it into a quick, painless, and super-efficient process. Thanks to some clever Python tricks, Joe is no longer stuck playing switch backup whack-a-mole—he’s free to do more of the cool, high-impact stuff he actually wants to do.
The best part? This isn’t just a one-time win—it’s a scalable, repeatable solution. Whether you’ve got 10 switches or 1000, this script has got your back! So, if you’re drowning in repetitive tasks and wishing for a magic wand—well, turns out, Python is the closest thing to that wand! ๐ช
In short: Automation = More Time for the Fun Stuff. You can thank us later. ๐