""" Sites API blueprint. Handles endpoints for managing reusable site definitions, including CIDR ranges and IP-level overrides. """ import logging from flask import Blueprint, current_app, jsonify, request from sqlalchemy.exc import SQLAlchemyError from web.auth.decorators import api_auth_required from web.services.site_service import SiteService from web.utils.pagination import validate_page_params bp = Blueprint('sites', __name__) logger = logging.getLogger(__name__) @bp.route('', methods=['GET']) @api_auth_required def list_sites(): """ List all sites with pagination. Query params: page: Page number (default: 1) per_page: Items per page (default: 20, max: 100) all: If 'true', returns all sites without pagination (for dropdowns) Returns: JSON response with sites list and pagination info """ try: # Check if requesting all sites (no pagination) if request.args.get('all', '').lower() == 'true': site_service = SiteService(current_app.db_session) sites = site_service.list_all_sites() logger.info(f"Listed all sites (count={len(sites)})") return jsonify({'sites': sites}) # Get and validate query parameters page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) # Validate pagination params page, per_page = validate_page_params(page, per_page) # Get sites from service site_service = SiteService(current_app.db_session) paginated_result = site_service.list_sites(page=page, per_page=per_page) logger.info(f"Listed sites: page={page}, per_page={per_page}, total={paginated_result.total}") return jsonify({ 'sites': paginated_result.items, 'total': paginated_result.total, 'page': paginated_result.page, 'per_page': paginated_result.per_page, 'total_pages': paginated_result.pages, 'has_prev': paginated_result.has_prev, 'has_next': paginated_result.has_next }) except ValueError as e: logger.warning(f"Invalid request parameters: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error listing sites: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to retrieve sites' }), 500 except Exception as e: logger.error(f"Unexpected error listing sites: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/', methods=['GET']) @api_auth_required def get_site(site_id): """ Get details for a specific site. Args: site_id: Site ID Returns: JSON response with site details including CIDRs and IP overrides """ try: site_service = SiteService(current_app.db_session) site = site_service.get_site(site_id) if not site: logger.warning(f"Site not found: {site_id}") return jsonify({ 'error': 'Not found', 'message': f'Site with ID {site_id} not found' }), 404 logger.info(f"Retrieved site details: {site_id}") return jsonify(site) except SQLAlchemyError as e: logger.error(f"Database error retrieving site {site_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to retrieve site' }), 500 except Exception as e: logger.error(f"Unexpected error retrieving site {site_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('', methods=['POST']) @api_auth_required def create_site(): """ Create a new site. Request body: name: Site name (required, must be unique) description: Site description (optional) cidrs: List of CIDR definitions (optional, but recommended) [ { "cidr": "10.0.0.0/24", "expected_ping": true, "expected_tcp_ports": [22, 80, 443], "expected_udp_ports": [53] } ] Returns: JSON response with created site data """ try: data = request.get_json() or {} # Validate required fields name = data.get('name') if not name: logger.warning("Site creation request missing name") return jsonify({ 'error': 'Invalid request', 'message': 'name is required' }), 400 description = data.get('description') cidrs = data.get('cidrs', []) # Validate cidrs is a list if not isinstance(cidrs, list): return jsonify({ 'error': 'Invalid request', 'message': 'cidrs must be a list' }), 400 # Create site site_service = SiteService(current_app.db_session) site = site_service.create_site( name=name, description=description, cidrs=cidrs if cidrs else None ) logger.info(f"Created site '{name}' (id={site['id']})") return jsonify(site), 201 except ValueError as e: logger.warning(f"Invalid site creation request: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error creating site: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to create site' }), 500 except Exception as e: logger.error(f"Unexpected error creating site: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/', methods=['PUT']) @api_auth_required def update_site(site_id): """ Update site metadata (name and/or description). Args: site_id: Site ID Request body: name: New site name (optional, must be unique) description: New description (optional) Returns: JSON response with updated site data """ try: data = request.get_json() or {} name = data.get('name') description = data.get('description') # Update site site_service = SiteService(current_app.db_session) site = site_service.update_site( site_id=site_id, name=name, description=description ) logger.info(f"Updated site {site_id}") return jsonify(site) except ValueError as e: logger.warning(f"Invalid site update request: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error updating site {site_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to update site' }), 500 except Exception as e: logger.error(f"Unexpected error updating site {site_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/', methods=['DELETE']) @api_auth_required def delete_site(site_id): """ Delete a site. Prevents deletion if site is used in any scan. Args: site_id: Site ID Returns: JSON response with success message """ try: site_service = SiteService(current_app.db_session) site_service.delete_site(site_id) logger.info(f"Deleted site {site_id}") return jsonify({ 'message': f'Site {site_id} deleted successfully' }) except ValueError as e: logger.warning(f"Cannot delete site {site_id}: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error deleting site {site_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to delete site' }), 500 except Exception as e: logger.error(f"Unexpected error deleting site {site_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('//cidrs', methods=['POST']) @api_auth_required def add_cidr(site_id): """ Add a CIDR range to a site. Args: site_id: Site ID Request body: cidr: CIDR notation (required, e.g., "10.0.0.0/24") expected_ping: Expected ping response (optional) expected_tcp_ports: List of expected TCP ports (optional) expected_udp_ports: List of expected UDP ports (optional) Returns: JSON response with created CIDR data """ try: data = request.get_json() or {} # Validate required fields cidr = data.get('cidr') if not cidr: logger.warning("CIDR creation request missing cidr") return jsonify({ 'error': 'Invalid request', 'message': 'cidr is required' }), 400 expected_ping = data.get('expected_ping') expected_tcp_ports = data.get('expected_tcp_ports', []) expected_udp_ports = data.get('expected_udp_ports', []) # Add CIDR site_service = SiteService(current_app.db_session) cidr_data = site_service.add_cidr( site_id=site_id, cidr=cidr, expected_ping=expected_ping, expected_tcp_ports=expected_tcp_ports, expected_udp_ports=expected_udp_ports ) logger.info(f"Added CIDR '{cidr}' to site {site_id}") return jsonify(cidr_data), 201 except ValueError as e: logger.warning(f"Invalid CIDR creation request: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error adding CIDR to site {site_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to add CIDR' }), 500 except Exception as e: logger.error(f"Unexpected error adding CIDR to site {site_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('//cidrs/', methods=['DELETE']) @api_auth_required def remove_cidr(site_id, cidr_id): """ Remove a CIDR range from a site. Prevents removal if it's the last CIDR. Args: site_id: Site ID cidr_id: CIDR ID Returns: JSON response with success message """ try: site_service = SiteService(current_app.db_session) site_service.remove_cidr(site_id, cidr_id) logger.info(f"Removed CIDR {cidr_id} from site {site_id}") return jsonify({ 'message': f'CIDR {cidr_id} removed successfully' }) except ValueError as e: logger.warning(f"Cannot remove CIDR {cidr_id} from site {site_id}: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error removing CIDR {cidr_id} from site {site_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to remove CIDR' }), 500 except Exception as e: logger.error(f"Unexpected error removing CIDR {cidr_id} from site {site_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('//cidrs//ips', methods=['POST']) @api_auth_required def add_ip_override(site_id, cidr_id): """ Add an IP-level expectation override within a CIDR. Args: site_id: Site ID (for validation) cidr_id: CIDR ID Request body: ip_address: IP address (required) expected_ping: Override ping expectation (optional) expected_tcp_ports: Override TCP ports expectation (optional) expected_udp_ports: Override UDP ports expectation (optional) Returns: JSON response with created IP override data """ try: data = request.get_json() or {} # Validate required fields ip_address = data.get('ip_address') if not ip_address: logger.warning("IP override creation request missing ip_address") return jsonify({ 'error': 'Invalid request', 'message': 'ip_address is required' }), 400 expected_ping = data.get('expected_ping') expected_tcp_ports = data.get('expected_tcp_ports', []) expected_udp_ports = data.get('expected_udp_ports', []) # Add IP override site_service = SiteService(current_app.db_session) ip_data = site_service.add_ip_override( cidr_id=cidr_id, ip_address=ip_address, expected_ping=expected_ping, expected_tcp_ports=expected_tcp_ports, expected_udp_ports=expected_udp_ports ) logger.info(f"Added IP override '{ip_address}' to CIDR {cidr_id} in site {site_id}") return jsonify(ip_data), 201 except ValueError as e: logger.warning(f"Invalid IP override creation request: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error adding IP override to CIDR {cidr_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to add IP override' }), 500 except Exception as e: logger.error(f"Unexpected error adding IP override to CIDR {cidr_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('//cidrs//ips/', methods=['DELETE']) @api_auth_required def remove_ip_override(site_id, cidr_id, ip_id): """ Remove an IP-level override. Args: site_id: Site ID (for validation) cidr_id: CIDR ID ip_id: IP override ID Returns: JSON response with success message """ try: site_service = SiteService(current_app.db_session) site_service.remove_ip_override(cidr_id, ip_id) logger.info(f"Removed IP override {ip_id} from CIDR {cidr_id} in site {site_id}") return jsonify({ 'message': f'IP override {ip_id} removed successfully' }) except ValueError as e: logger.warning(f"Cannot remove IP override {ip_id}: {str(e)}") return jsonify({ 'error': 'Invalid request', 'message': str(e) }), 400 except SQLAlchemyError as e: logger.error(f"Database error removing IP override {ip_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to remove IP override' }), 500 except Exception as e: logger.error(f"Unexpected error removing IP override {ip_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('//usage', methods=['GET']) @api_auth_required def get_site_usage(site_id): """ Get list of scans that use this site. Args: site_id: Site ID Returns: JSON response with list of scans """ try: site_service = SiteService(current_app.db_session) # First check if site exists site = site_service.get_site(site_id) if not site: logger.warning(f"Site not found: {site_id}") return jsonify({ 'error': 'Not found', 'message': f'Site with ID {site_id} not found' }), 404 scans = site_service.get_scan_usage(site_id) logger.info(f"Retrieved usage for site {site_id} (count={len(scans)})") return jsonify({ 'site_id': site_id, 'site_name': site['name'], 'scans': scans, 'count': len(scans) }) except SQLAlchemyError as e: logger.error(f"Database error retrieving site usage {site_id}: {str(e)}") return jsonify({ 'error': 'Database error', 'message': 'Failed to retrieve site usage' }), 500 except Exception as e: logger.error(f"Unexpected error retrieving site usage {site_id}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500