Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |