stage 1 of doing new cidrs/ site setup
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
"""
|
||||
Configs API blueprint.
|
||||
|
||||
Handles endpoints for managing scan configuration files, including CSV/YAML upload,
|
||||
template download, and config management.
|
||||
Handles endpoints for managing scan configurations stored in the database.
|
||||
Provides REST API for creating, updating, and deleting configs that reference sites.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import io
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from werkzeug.utils import secure_filename
|
||||
from flask import Blueprint, jsonify, request, current_app
|
||||
|
||||
from web.auth.decorators import api_auth_required
|
||||
from web.services.config_service import ConfigService
|
||||
@@ -17,32 +15,40 @@ bp = Blueprint('configs', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Database-based Config Endpoints (Primary)
|
||||
# ============================================================================
|
||||
|
||||
@bp.route('', methods=['GET'])
|
||||
@api_auth_required
|
||||
def list_configs():
|
||||
"""
|
||||
List all config files with metadata.
|
||||
List all scan configurations from database.
|
||||
|
||||
Returns:
|
||||
JSON response with list of configs:
|
||||
{
|
||||
"configs": [
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"title": "Prod Scan",
|
||||
"path": "/app/configs/prod-scan.yaml",
|
||||
"created_at": "2025-11-15T10:30:00Z",
|
||||
"size_bytes": 1234,
|
||||
"used_by_schedules": ["Daily Scan"]
|
||||
"id": 1,
|
||||
"title": "Production Scan",
|
||||
"description": "Weekly production scan",
|
||||
"site_count": 3,
|
||||
"sites": [
|
||||
{"id": 1, "name": "Production DC"},
|
||||
{"id": 2, "name": "DMZ"}
|
||||
],
|
||||
"created_at": "2025-11-19T10:30:00Z",
|
||||
"updated_at": "2025-11-19T10:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
config_service = ConfigService()
|
||||
configs = config_service.list_configs()
|
||||
config_service = ConfigService(db_session=current_app.db_session)
|
||||
configs = config_service.list_configs_db()
|
||||
|
||||
logger.info(f"Listed {len(configs)} config files")
|
||||
logger.info(f"Listed {len(configs)} configs from database")
|
||||
|
||||
return jsonify({
|
||||
'configs': configs
|
||||
@@ -56,78 +62,38 @@ def list_configs():
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['GET'])
|
||||
@bp.route('', methods=['POST'])
|
||||
@api_auth_required
|
||||
def get_config(filename: str):
|
||||
def create_config():
|
||||
"""
|
||||
Get config file content and parsed data.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Returns:
|
||||
JSON response with config content:
|
||||
{
|
||||
"filename": "prod-scan.yaml",
|
||||
"content": "title: Prod Scan\n...",
|
||||
"parsed": {"title": "Prod Scan", "sites": [...]}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
logger.info(f"Retrieved config file: {filename}")
|
||||
|
||||
return jsonify(config_data)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid config file: {filename} - {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Invalid config',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/create-from-cidr', methods=['POST'])
|
||||
@api_auth_required
|
||||
def create_from_cidr():
|
||||
"""
|
||||
Create config from CIDR range.
|
||||
Create a new scan configuration in the database.
|
||||
|
||||
Request:
|
||||
JSON with:
|
||||
{
|
||||
"title": "My Scan",
|
||||
"cidr": "10.0.0.0/24",
|
||||
"site_name": "Production" (optional),
|
||||
"ping_default": false (optional)
|
||||
"title": "Production Scan",
|
||||
"description": "Weekly production scan (optional)",
|
||||
"site_ids": [1, 2, 3]
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with created config info:
|
||||
JSON response with created config:
|
||||
{
|
||||
"success": true,
|
||||
"filename": "my-scan.yaml",
|
||||
"preview": "title: My Scan\n..."
|
||||
"config": {
|
||||
"id": 1,
|
||||
"title": "Production Scan",
|
||||
"description": "...",
|
||||
"site_count": 3,
|
||||
"sites": [...],
|
||||
"created_at": "2025-11-19T10:30:00Z",
|
||||
"updated_at": "2025-11-19T10:30:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 400: Validation error or missing fields
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
@@ -145,272 +111,192 @@ def create_from_cidr():
|
||||
'message': 'Missing required field: title'
|
||||
}), 400
|
||||
|
||||
if 'cidr' not in data:
|
||||
if 'site_ids' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: cidr'
|
||||
'message': 'Missing required field: site_ids'
|
||||
}), 400
|
||||
|
||||
title = data['title']
|
||||
cidr = data['cidr']
|
||||
site_name = data.get('site_name', None)
|
||||
ping_default = data.get('ping_default', False)
|
||||
description = data.get('description', None)
|
||||
site_ids = data['site_ids']
|
||||
|
||||
# Validate title
|
||||
if not title or not title.strip():
|
||||
if not isinstance(site_ids, list):
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': 'Title cannot be empty'
|
||||
'error': 'Bad request',
|
||||
'message': 'Field site_ids must be an array'
|
||||
}), 400
|
||||
|
||||
# Create config from CIDR
|
||||
config_service = ConfigService()
|
||||
filename, yaml_preview = config_service.create_from_cidr(
|
||||
title=title,
|
||||
cidr=cidr,
|
||||
site_name=site_name,
|
||||
ping_default=ping_default
|
||||
)
|
||||
# Create config
|
||||
config_service = ConfigService(db_session=current_app.db_session)
|
||||
config = config_service.create_config(title, description, site_ids)
|
||||
|
||||
logger.info(f"Created config from CIDR {cidr}: {filename}")
|
||||
logger.info(f"Created config: {config['title']} (ID: {config['id']})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': filename,
|
||||
'preview': yaml_preview
|
||||
})
|
||||
'config': config
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"CIDR validation failed: {str(e)}")
|
||||
logger.warning(f"Config validation failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating config from CIDR: {str(e)}", exc_info=True)
|
||||
logger.error(f"Unexpected error creating config: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/upload-yaml', methods=['POST'])
|
||||
@bp.route('/<int:config_id>', methods=['GET'])
|
||||
@api_auth_required
|
||||
def upload_yaml():
|
||||
def get_config(config_id: int):
|
||||
"""
|
||||
Upload YAML config file directly.
|
||||
|
||||
Request:
|
||||
multipart/form-data with 'file' field containing YAML file
|
||||
Optional 'filename' field for custom filename
|
||||
|
||||
Returns:
|
||||
JSON response with created config info:
|
||||
{
|
||||
"success": true,
|
||||
"filename": "prod-scan.yaml"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Check if file is present
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'No file provided'
|
||||
}), 400
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
# Check if file is selected
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'No file selected'
|
||||
}), 400
|
||||
|
||||
# Check file extension
|
||||
if not (file.filename.endswith('.yaml') or file.filename.endswith('.yml')):
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'File must be a YAML file (.yaml or .yml extension)'
|
||||
}), 400
|
||||
|
||||
# Read YAML content
|
||||
yaml_content = file.read().decode('utf-8')
|
||||
|
||||
# Get filename (use uploaded filename or custom)
|
||||
filename = request.form.get('filename', file.filename)
|
||||
filename = secure_filename(filename)
|
||||
|
||||
# Create config from YAML
|
||||
config_service = ConfigService()
|
||||
final_filename = config_service.create_from_yaml(filename, yaml_content)
|
||||
|
||||
logger.info(f"Created config from YAML upload: {final_filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': final_filename
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"YAML validation failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except UnicodeDecodeError:
|
||||
logger.warning("YAML file encoding error")
|
||||
return jsonify({
|
||||
'error': 'Encoding error',
|
||||
'message': 'YAML file must be UTF-8 encoded'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error uploading YAML: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.route('/<filename>/download', methods=['GET'])
|
||||
@api_auth_required
|
||||
def download_config(filename: str):
|
||||
"""
|
||||
Download existing config file.
|
||||
Get a scan configuration by ID.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
config_id: Configuration ID
|
||||
|
||||
Returns:
|
||||
YAML file download
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_data = config_service.get_config(filename)
|
||||
|
||||
# Create file-like object
|
||||
yaml_file = io.BytesIO(config_data['content'].encode('utf-8'))
|
||||
yaml_file.seek(0)
|
||||
|
||||
logger.info(f"Config file downloaded: {filename}")
|
||||
|
||||
# Send file
|
||||
return send_file(
|
||||
yaml_file,
|
||||
mimetype='application/x-yaml',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error downloading config {filename}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def update_config(filename: str):
|
||||
"""
|
||||
Update existing config file with new YAML content.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
|
||||
Request:
|
||||
JSON with:
|
||||
JSON response with config details:
|
||||
{
|
||||
"content": "title: My Scan\nsites: ..."
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with success status:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Config updated successfully"
|
||||
"id": 1,
|
||||
"title": "Production Scan",
|
||||
"description": "...",
|
||||
"site_count": 3,
|
||||
"sites": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Production DC",
|
||||
"description": "...",
|
||||
"cidr_count": 5
|
||||
}
|
||||
],
|
||||
"created_at": "2025-11-19T10:30:00Z",
|
||||
"updated_at": "2025-11-19T10:30:00Z"
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 400: Invalid YAML or config structure
|
||||
- 404: Config file not found
|
||||
- 404: Config not found
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
config_service = ConfigService(db_session=current_app.db_session)
|
||||
config = config_service.get_config_by_id(config_id)
|
||||
|
||||
data = request.get_json()
|
||||
logger.info(f"Retrieved config: {config['title']} (ID: {config_id})")
|
||||
|
||||
if not data or 'content' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: content'
|
||||
}), 400
|
||||
return jsonify(config)
|
||||
|
||||
yaml_content = data['content']
|
||||
|
||||
# Update config
|
||||
config_service = ConfigService()
|
||||
config_service.update_config(filename, yaml_content)
|
||||
|
||||
logger.info(f"Updated config file: {filename}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Config updated successfully'
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
except ValueError as e:
|
||||
logger.warning(f"Config not found: {config_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid config content for {filename}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error updating config {filename}: {str(e)}", exc_info=True)
|
||||
logger.error(f"Unexpected error getting config {config_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<filename>', methods=['DELETE'])
|
||||
@bp.route('/<int:config_id>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def delete_config(filename: str):
|
||||
def update_config(config_id: int):
|
||||
"""
|
||||
Delete config file and cascade delete associated schedules.
|
||||
|
||||
When a config is deleted, all schedules using that config (both enabled
|
||||
and disabled) are automatically deleted as well.
|
||||
Update an existing scan configuration.
|
||||
|
||||
Args:
|
||||
filename: Config filename
|
||||
config_id: Configuration ID
|
||||
|
||||
Request:
|
||||
JSON with (all fields optional):
|
||||
{
|
||||
"title": "New Title",
|
||||
"description": "New Description",
|
||||
"site_ids": [1, 2, 3]
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with updated config:
|
||||
{
|
||||
"success": true,
|
||||
"config": {...}
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 400: Validation error
|
||||
- 404: Config not found
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Request body must be JSON'
|
||||
}), 400
|
||||
|
||||
title = data.get('title', None)
|
||||
description = data.get('description', None)
|
||||
site_ids = data.get('site_ids', None)
|
||||
|
||||
if site_ids is not None and not isinstance(site_ids, list):
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Field site_ids must be an array'
|
||||
}), 400
|
||||
|
||||
# Update config
|
||||
config_service = ConfigService(db_session=current_app.db_session)
|
||||
config = config_service.update_config(config_id, title, description, site_ids)
|
||||
|
||||
logger.info(f"Updated config: {config['title']} (ID: {config_id})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': config
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
if 'not found' in str(e).lower():
|
||||
logger.warning(f"Config not found: {config_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
else:
|
||||
logger.warning(f"Config validation failed: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error updating config {config_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:config_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def delete_config(config_id: int):
|
||||
"""
|
||||
Delete a scan configuration.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID
|
||||
|
||||
Returns:
|
||||
JSON response with success status:
|
||||
@@ -420,32 +306,155 @@ def delete_config(filename: str):
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 404: Config file not found
|
||||
- 404: Config not found
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Sanitize filename
|
||||
filename = secure_filename(filename)
|
||||
config_service = ConfigService(db_session=current_app.db_session)
|
||||
config_service.delete_config(config_id)
|
||||
|
||||
config_service = ConfigService()
|
||||
config_service.delete_config(filename)
|
||||
|
||||
logger.info(f"Deleted config file: {filename}")
|
||||
logger.info(f"Deleted config (ID: {config_id})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Config deleted successfully'
|
||||
})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Config file not found: {filename}")
|
||||
except ValueError as e:
|
||||
logger.warning(f"Config not found: {config_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
|
||||
logger.error(f"Unexpected error deleting config {config_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:config_id>/sites', methods=['POST'])
|
||||
@api_auth_required
|
||||
def add_site_to_config(config_id: int):
|
||||
"""
|
||||
Add a site to an existing config.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID
|
||||
|
||||
Request:
|
||||
JSON with:
|
||||
{
|
||||
"site_id": 5
|
||||
}
|
||||
|
||||
Returns:
|
||||
JSON response with updated config:
|
||||
{
|
||||
"success": true,
|
||||
"config": {...}
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 400: Validation error or site already in config
|
||||
- 404: Config or site not found
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'site_id' not in data:
|
||||
return jsonify({
|
||||
'error': 'Bad request',
|
||||
'message': 'Missing required field: site_id'
|
||||
}), 400
|
||||
|
||||
site_id = data['site_id']
|
||||
|
||||
# Add site to config
|
||||
config_service = ConfigService(db_session=current_app.db_session)
|
||||
config = config_service.add_site_to_config(config_id, site_id)
|
||||
|
||||
logger.info(f"Added site {site_id} to config {config_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': config
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
if 'not found' in str(e).lower():
|
||||
logger.warning(f"Config or site not found: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
else:
|
||||
logger.warning(f"Validation error: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error adding site to config: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:config_id>/sites/<int:site_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def remove_site_from_config(config_id: int, site_id: int):
|
||||
"""
|
||||
Remove a site from a config.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID
|
||||
site_id: Site ID to remove
|
||||
|
||||
Returns:
|
||||
JSON response with updated config:
|
||||
{
|
||||
"success": true,
|
||||
"config": {...}
|
||||
}
|
||||
|
||||
Error responses:
|
||||
- 400: Validation error (e.g., last site cannot be removed)
|
||||
- 404: Config not found or site not in config
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
config_service = ConfigService(db_session=current_app.db_session)
|
||||
config = config_service.remove_site_from_config(config_id, site_id)
|
||||
|
||||
logger.info(f"Removed site {site_id} from config {config_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'config': config
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
if 'not found' in str(e).lower() or 'not in this config' in str(e).lower():
|
||||
logger.warning(f"Config or site not found: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': str(e)
|
||||
}), 404
|
||||
else:
|
||||
logger.warning(f"Validation error: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Validation error',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error removing site from config: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
|
||||
564
app/web/api/sites.py
Normal file
564
app/web/api/sites.py
Normal file
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user