565 lines
17 KiB
Python
565 lines
17 KiB
Python
"""
|
|
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('/<int:site_id>', 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('/<int:site_id>', 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('/<int:site_id>', 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('/<int:site_id>/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('/<int:site_id>/cidrs/<int:cidr_id>', 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('/<int:site_id>/cidrs/<int:cidr_id>/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('/<int:site_id>/cidrs/<int:cidr_id>/ips/<int:ip_id>', 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('/<int:site_id>/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
|