stage 1 of doing new cidrs/ site setup

This commit is contained in:
2025-11-19 13:39:27 -06:00
parent 4a4c33a10b
commit 034f146fa1
16 changed files with 3998 additions and 609 deletions

View File

@@ -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'