overgrowth / agent /batfish_client.py
Graham Paasch
feat: Add Batfish static analysis (Todo #3 - Stage 6b)
264a642
Raw
History Blame
14.4 kB
"""
Batfish Integration for Static Network Analysis
Pre-deployment validation of configs without touching live gear
"""
import os
import logging
from typing import Dict, List, Optional, Any
from pathlib import Path
import tempfile
import shutil
logger = logging.getLogger(__name__)
class BatfishAnalysis:
"""Results from Batfish static analysis"""
def __init__(self):
self.reachability_passed = False
self.routing_loops = []
self.acl_issues = []
self.undefined_references = []
self.unused_structures = []
self.forwarding_errors = []
self.all_passed = False
def to_dict(self) -> Dict[str, Any]:
return {
'reachability_passed': self.reachability_passed,
'routing_loops': self.routing_loops,
'acl_issues': self.acl_issues,
'undefined_references': self.undefined_references,
'unused_structures': self.unused_structures,
'forwarding_errors': self.forwarding_errors,
'all_passed': self.all_passed
}
class BatfishClient:
"""
Client for Batfish network analysis
Performs static analysis on network configurations
"""
def __init__(self, host: str = "localhost", use_batfish: bool = True):
"""
Initialize Batfish client
Args:
host: Batfish service hostname
use_batfish: Enable Batfish (False for mock mode)
"""
self.host = host
self.use_batfish = use_batfish
self.mock_mode = True
if use_batfish:
try:
from pybatfish.client.commands import bf_session, bf_set_network, bf_init_snapshot
from pybatfish.question import bfq, load_questions
from pybatfish.datamodel import HeaderConstraints
self.bf_session = bf_session
self.bf_set_network = bf_set_network
self.bf_init_snapshot = bf_init_snapshot
self.bfq = bfq
self.load_questions = load_questions
self.HeaderConstraints = HeaderConstraints
# Connect to Batfish service
bf_session.host = host
load_questions()
self.mock_mode = False
logger.info(f"Connected to Batfish at {host}")
except ImportError:
logger.warning("pybatfish not installed - using mock mode")
logger.info("Install with: pip install pybatfish")
except Exception as e:
logger.warning(f"Failed to connect to Batfish: {e}")
logger.info("Using mock mode")
def analyze_configs(
self,
configs: Dict[str, str],
network_name: str = "overgrowth-analysis"
) -> BatfishAnalysis:
"""
Analyze network configurations
Args:
configs: Dict mapping device names to config strings
network_name: Name for this analysis snapshot
Returns:
BatfishAnalysis with results
"""
if self.mock_mode:
return self._mock_analysis(configs)
analysis = BatfishAnalysis()
try:
# Create temp directory for configs
snapshot_dir = Path(tempfile.mkdtemp(prefix="batfish_"))
configs_dir = snapshot_dir / "configs"
configs_dir.mkdir()
# Write configs to files
for device_name, config in configs.items():
config_file = configs_dir / f"{device_name}.cfg"
config_file.write_text(config)
logger.info(f"Created snapshot with {len(configs)} device configs")
# Initialize Batfish snapshot
self.bf_set_network(network_name)
self.bf_init_snapshot(str(snapshot_dir), name="candidate", overwrite=True)
# Run analysis questions
analysis = self._run_batfish_questions()
# Cleanup temp directory
shutil.rmtree(snapshot_dir)
except Exception as e:
logger.error(f"Batfish analysis failed: {e}")
analysis.all_passed = False
return analysis
def _run_batfish_questions(self) -> BatfishAnalysis:
"""Run Batfish analysis questions"""
analysis = BatfishAnalysis()
try:
# 1. Check for undefined references
logger.info("Checking for undefined references...")
undef_refs = self.bfq.undefinedReferences().answer().frame()
if not undef_refs.empty:
analysis.undefined_references = undef_refs.to_dict('records')
logger.warning(f"Found {len(undef_refs)} undefined references")
# 2. Check for unused structures
logger.info("Checking for unused structures...")
unused = self.bfq.unusedStructures().answer().frame()
if not unused.empty:
analysis.unused_structures = unused.to_dict('records')
logger.info(f"Found {len(unused)} unused structures")
# 3. Check routing loops
logger.info("Checking for routing loops...")
loops = self.bfq.detectLoops().answer().frame()
if not loops.empty:
analysis.routing_loops = loops.to_dict('records')
logger.error(f"Found {len(loops)} routing loops!")
# 4. Validate reachability
logger.info("Validating reachability...")
reach = self.bfq.reachability().answer().frame()
analysis.reachability_passed = reach.empty or reach['Action'].str.contains('ACCEPT').any()
# 5. Check for forwarding errors
logger.info("Checking for forwarding errors...")
fwd_errors = self.bfq.detectForwardingLoops().answer().frame()
if not fwd_errors.empty:
analysis.forwarding_errors = fwd_errors.to_dict('records')
logger.error(f"Found {len(fwd_errors)} forwarding errors")
# Overall pass/fail
analysis.all_passed = (
len(analysis.undefined_references) == 0 and
len(analysis.routing_loops) == 0 and
len(analysis.forwarding_errors) == 0 and
analysis.reachability_passed
)
if analysis.all_passed:
logger.info("✓ Batfish analysis PASSED - no critical issues")
else:
logger.warning("✗ Batfish analysis found issues")
except Exception as e:
logger.error(f"Error running Batfish questions: {e}")
analysis.all_passed = False
return analysis
def _mock_analysis(self, configs: Dict[str, str]) -> BatfishAnalysis:
"""Mock analysis when Batfish unavailable"""
logger.info("Running mock Batfish analysis...")
analysis = BatfishAnalysis()
# Simple heuristic checks
for device, config in configs.items():
# Check for basic issues in config
if "no ip routing" in config.lower():
analysis.forwarding_errors.append({
'device': device,
'issue': 'Routing disabled',
'severity': 'WARNING'
})
# Check for undefined references (simple regex)
import re
vlan_refs = re.findall(r'switchport access vlan (\d+)', config, re.IGNORECASE)
vlan_defs = re.findall(r'vlan (\d+)', config, re.IGNORECASE)
undefined_vlans = set(vlan_refs) - set(vlan_defs)
for vlan in undefined_vlans:
analysis.undefined_references.append({
'device': device,
'type': 'VLAN',
'name': vlan,
'severity': 'ERROR'
})
# Mock passes if no critical errors
analysis.all_passed = len(analysis.undefined_references) == 0
analysis.reachability_passed = True
logger.info(f"Mock analysis complete: {len(configs)} configs checked")
return analysis
def validate_acl_behavior(
self,
src: str,
dst: str,
protocol: str = "TCP",
dst_port: int = 80
) -> bool:
"""
Test if traffic is permitted by ACLs
Args:
src: Source IP or network
dst: Destination IP or network
protocol: IP protocol (TCP, UDP, ICMP)
dst_port: Destination port number
Returns:
True if traffic is permitted
"""
if self.mock_mode:
logger.info(f"Mock ACL check: {src} -> {dst}:{dst_port}/{protocol} = PERMIT")
return True
try:
# Build header constraints
headers = self.HeaderConstraints(
srcIps=src,
dstIps=dst,
ipProtocols=[protocol],
dstPorts=str(dst_port)
)
# Query reachability with constraints
result = self.bfq.reachability(headers=headers).answer().frame()
# Check if any flow is accepted
permitted = not result.empty and result['Action'].str.contains('ACCEPT').any()
logger.info(f"ACL check: {src} -> {dst}:{dst_port}/{protocol} = {'PERMIT' if permitted else 'DENY'}")
return permitted
except Exception as e:
logger.error(f"ACL validation failed: {e}")
return False
def find_routing_issues(self) -> List[Dict[str, Any]]:
"""
Find routing protocol issues
Returns:
List of routing issues found
"""
if self.mock_mode:
return []
issues = []
try:
# Check for BGP issues
logger.info("Checking BGP sessions...")
bgp_edges = self.bfq.bgpEdges().answer().frame()
for idx, edge in bgp_edges.iterrows():
if edge.get('Status') != 'ESTABLISHED':
issues.append({
'type': 'BGP_SESSION_DOWN',
'node': edge.get('Node'),
'remote': edge.get('Remote_Node'),
'severity': 'ERROR'
})
# Check for OSPF issues
logger.info("Checking OSPF neighbors...")
ospf_edges = self.bfq.ospfEdges().answer().frame()
# Look for missing adjacencies
# (This is simplified - real check would be more complex)
except Exception as e:
logger.error(f"Error finding routing issues: {e}")
return issues
def test_failover_scenario(
self,
failed_device: str,
src: str,
dst: str
) -> bool:
"""
Test if network maintains connectivity when device fails
Args:
failed_device: Device to simulate failure
src: Source IP for reachability test
dst: Destination IP for reachability test
Returns:
True if network survives failure
"""
if self.mock_mode:
logger.info(f"Mock failover test: network survives {failed_device} failure")
return True
try:
# Deactivate device
logger.info(f"Simulating failure of {failed_device}...")
# Test reachability without failed device
headers = self.HeaderConstraints(srcIps=src, dstIps=dst)
result = self.bfq.reachability(
headers=headers,
forbiddenTransitNodes=failed_device
).answer().frame()
survives = not result.empty and result['Action'].str.contains('ACCEPT').any()
if survives:
logger.info(f"✓ Network survives {failed_device} failure")
else:
logger.warning(f"✗ Network fails when {failed_device} is down")
return survives
except Exception as e:
logger.error(f"Failover test failed: {e}")
return False
def generate_config_recommendations(self, analysis: BatfishAnalysis) -> List[str]:
"""
Generate recommendations based on analysis results
Args:
analysis: Batfish analysis results
Returns:
List of human-readable recommendations
"""
recommendations = []
if analysis.undefined_references:
recommendations.append(
f"Fix {len(analysis.undefined_references)} undefined references "
"(VLANs, ACLs, route-maps referenced but not defined)"
)
if analysis.routing_loops:
recommendations.append(
f"Resolve {len(analysis.routing_loops)} routing loops "
"(will cause packet storms and network meltdown)"
)
if analysis.forwarding_errors:
recommendations.append(
f"Fix {len(analysis.forwarding_errors)} forwarding errors "
"(traffic will be dropped or blackholed)"
)
if analysis.unused_structures:
recommendations.append(
f"Consider removing {len(analysis.unused_structures)} unused structures "
"(cleanup for maintainability)"
)
if not analysis.reachability_passed:
recommendations.append(
"Reachability test failed - verify routing and ACLs allow required traffic"
)
if not recommendations:
recommendations.append("✓ No issues found - configuration looks good!")
return recommendations