2026-01-25 18:01:48 +01:00

224 lines
6.9 KiB
Python

"""SSH and Netmiko integration for device push"""
from typing import Tuple, Optional
import asyncio
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
import logging
logger = logging.getLogger(__name__)
class SSHConnectionError(Exception):
"""SSH connection failed"""
pass
class SSHExecutor:
"""
Handles SSH connections to Cisco devices via Netmiko.
CRITICAL SECURITY:
- Credentials are passed in-memory only, never stored
- SSH connection is established per-push
- All commands are logged for audit
"""
def __init__(self, hostname: str, ip_address: str, username: str, password: str,
device_type: str = "cisco_ios", ssh_port: int = 22, timeout: int = 30):
"""
Initialize SSH executor.
Args:
hostname: Device hostname
ip_address: Device IP/FQDN
username: SSH username
password: SSH password
device_type: Netmiko device type (e.g., "cisco_ios", "cisco_xe")
ssh_port: SSH port
timeout: Connection timeout in seconds
"""
self.hostname = hostname
self.ip_address = ip_address
self.username = username
self.password = password
self.device_type = device_type
self.ssh_port = ssh_port
self.timeout = timeout
self.connection = None
def connect(self) -> bool:
"""
Establish SSH connection.
Returns:
True if successful
Raises:
SSHConnectionError if connection fails
"""
try:
device = {
"device_type": self.device_type,
"host": self.ip_address,
"username": self.username,
"password": self.password,
"port": self.ssh_port,
"timeout": self.timeout,
}
self.connection = ConnectHandler(**device)
logger.info(f"SSH connected to {self.hostname} ({self.ip_address})")
return True
except NetmikoAuthenticationException as e:
logger.error(f"SSH authentication failed for {self.hostname}: {e}")
raise SSHConnectionError(f"Authentication failed: {e}")
except NetmikoTimeoutException as e:
logger.error(f"SSH timeout connecting to {self.hostname}: {e}")
raise SSHConnectionError(f"Connection timeout: {e}")
except Exception as e:
logger.error(f"SSH connection error for {self.hostname}: {e}")
raise SSHConnectionError(f"Connection failed: {e}")
def send_commands(self, commands: list) -> str:
"""
Send commands to device and return output.
Args:
commands: List of CLI commands
Returns:
Device output
"""
if not self.connection:
raise SSHConnectionError("Not connected")
output = ""
try:
for cmd in commands:
logger.debug(f"Sending: {cmd}")
output += self.connection.send_command(cmd)
output += "\n"
except Exception as e:
logger.error(f"Command execution failed: {e}")
raise SSHConnectionError(f"Command failed: {e}")
return output
def get_running_config(self) -> str:
"""
Retrieve running configuration (for backup before push).
Returns:
Full running-config
"""
if not self.connection:
raise SSHConnectionError("Not connected")
try:
config = self.connection.send_command("show running-config")
logger.info(f"Retrieved running-config from {self.hostname}")
return config
except Exception as e:
logger.error(f"Failed to get running-config: {e}")
raise SSHConnectionError(f"Get config failed: {e}")
def save_running_config(self) -> bool:
"""
Save running-config to startup-config.
Returns:
True if successful
"""
if not self.connection:
raise SSHConnectionError("Not connected")
try:
output = self.connection.send_command("write memory")
logger.info(f"Saved config on {self.hostname}")
return "OK" in output or "successfully" in output.lower()
except Exception as e:
logger.error(f"Failed to save config: {e}")
raise SSHConnectionError(f"Save failed: {e}")
def disconnect(self):
"""Close SSH connection"""
if self.connection:
try:
self.connection.disconnect()
logger.info(f"SSH disconnected from {self.hostname}")
except Exception as e:
logger.warning(f"Error disconnecting from {self.hostname}: {e}")
self.connection = None
def __enter__(self):
"""Context manager support"""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager cleanup"""
self.disconnect()
async def push_to_device_async(
hostname: str,
ip_address: str,
commands: list,
username: str,
password: str,
dry_run: bool = False,
save_config: bool = True,
) -> Tuple[bool, str, Optional[str]]:
"""
Push commands to device asynchronously.
Args:
hostname, ip_address, commands, username, password: Device info
dry_run: If True, only validate, don't execute
save_config: If True, backup running-config before push
Returns:
(success, message, error_msg)
"""
executor = SSHExecutor(hostname, ip_address, username, password)
try:
# Run in thread pool (netmiko is blocking)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, executor.connect)
# Get backup if requested
backup = None
if save_config:
backup = await loop.run_in_executor(None, executor.get_running_config)
# Dry run mode
if dry_run:
executor.disconnect()
return True, "Dry-run completed. Configuration validated.", None
# Send commands
output = await loop.run_in_executor(None, executor.send_commands, commands)
# Save config
if save_config:
await loop.run_in_executor(None, executor.save_running_config)
executor.disconnect()
return True, "Configuration pushed successfully", None
except SSHConnectionError as e:
executor.disconnect()
return False, "", str(e)
except Exception as e:
executor.disconnect()
logger.error(f"Unexpected error during push: {e}")
return False, "", str(e)