stage 1 of doing new cidrs/ site setup
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Config Service - Business logic for config file management
|
||||
Config Service - Business logic for config management
|
||||
|
||||
This service handles all operations related to scan configuration files,
|
||||
including creation, validation, listing, and deletion.
|
||||
This service handles all operations related to scan configurations,
|
||||
both database-stored (primary) and file-based (deprecated).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -13,26 +13,343 @@ from typing import Dict, List, Tuple, Any, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from werkzeug.utils import secure_filename
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class ConfigService:
|
||||
"""Business logic for config management"""
|
||||
|
||||
def __init__(self, configs_dir: str = '/app/configs'):
|
||||
def __init__(self, db_session: Session = None, configs_dir: str = '/app/configs'):
|
||||
"""
|
||||
Initialize the config service.
|
||||
|
||||
Args:
|
||||
configs_dir: Directory where config files are stored
|
||||
db_session: SQLAlchemy database session (for database operations)
|
||||
configs_dir: Directory where legacy config files are stored
|
||||
"""
|
||||
self.db = db_session
|
||||
self.configs_dir = configs_dir
|
||||
|
||||
# Ensure configs directory exists
|
||||
# Ensure configs directory exists (for legacy YAML configs)
|
||||
os.makedirs(self.configs_dir, exist_ok=True)
|
||||
|
||||
def list_configs(self) -> List[Dict[str, Any]]:
|
||||
# ============================================================================
|
||||
# Database-based Config Operations (Primary)
|
||||
# ============================================================================
|
||||
|
||||
def create_config(self, title: str, description: Optional[str], site_ids: List[int]) -> Dict[str, Any]:
|
||||
"""
|
||||
List all config files with metadata.
|
||||
Create a new scan configuration in the database.
|
||||
|
||||
Args:
|
||||
title: Configuration title
|
||||
description: Optional configuration description
|
||||
site_ids: List of site IDs to include in this config
|
||||
|
||||
Returns:
|
||||
Created config as dictionary:
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Production Scan",
|
||||
"description": "...",
|
||||
"site_count": 3,
|
||||
"sites": [...],
|
||||
"created_at": "2025-11-19T10:30:00Z",
|
||||
"updated_at": "2025-11-19T10:30:00Z"
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails or sites don't exist
|
||||
"""
|
||||
if not title or not title.strip():
|
||||
raise ValueError("Title is required")
|
||||
|
||||
if not site_ids or len(site_ids) == 0:
|
||||
raise ValueError("At least one site must be selected")
|
||||
|
||||
# Import models here to avoid circular imports
|
||||
from web.models import ScanConfig, ScanConfigSite, Site
|
||||
|
||||
# Verify all sites exist
|
||||
existing_sites = self.db.query(Site).filter(Site.id.in_(site_ids)).all()
|
||||
if len(existing_sites) != len(site_ids):
|
||||
found_ids = {s.id for s in existing_sites}
|
||||
missing_ids = set(site_ids) - found_ids
|
||||
raise ValueError(f"Sites not found: {missing_ids}")
|
||||
|
||||
# Create config
|
||||
config = ScanConfig(
|
||||
title=title.strip(),
|
||||
description=description.strip() if description else None,
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
self.db.add(config)
|
||||
self.db.flush() # Get the config ID
|
||||
|
||||
# Create associations
|
||||
for site_id in site_ids:
|
||||
assoc = ScanConfigSite(
|
||||
config_id=config.id,
|
||||
site_id=site_id,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
self.db.add(assoc)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return self.get_config_by_id(config.id)
|
||||
|
||||
def get_config_by_id(self, config_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a scan configuration by ID.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID
|
||||
|
||||
Returns:
|
||||
Config as dictionary with sites
|
||||
|
||||
Raises:
|
||||
ValueError: If config not found
|
||||
"""
|
||||
from web.models import ScanConfig
|
||||
|
||||
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||
|
||||
if not config:
|
||||
raise ValueError(f"Config with ID {config_id} not found")
|
||||
|
||||
# Get associated sites
|
||||
sites = []
|
||||
for assoc in config.site_associations:
|
||||
site = assoc.site
|
||||
sites.append({
|
||||
'id': site.id,
|
||||
'name': site.name,
|
||||
'description': site.description,
|
||||
'cidr_count': len(site.cidrs)
|
||||
})
|
||||
|
||||
return {
|
||||
'id': config.id,
|
||||
'title': config.title,
|
||||
'description': config.description,
|
||||
'site_count': len(sites),
|
||||
'sites': sites,
|
||||
'created_at': config.created_at.isoformat() + 'Z' if config.created_at else None,
|
||||
'updated_at': config.updated_at.isoformat() + 'Z' if config.updated_at else None
|
||||
}
|
||||
|
||||
def list_configs_db(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all scan configurations from database.
|
||||
|
||||
Returns:
|
||||
List of config dictionaries with metadata
|
||||
"""
|
||||
from web.models import ScanConfig
|
||||
|
||||
configs = self.db.query(ScanConfig).order_by(ScanConfig.updated_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for config in configs:
|
||||
sites = []
|
||||
for assoc in config.site_associations:
|
||||
site = assoc.site
|
||||
sites.append({
|
||||
'id': site.id,
|
||||
'name': site.name
|
||||
})
|
||||
|
||||
result.append({
|
||||
'id': config.id,
|
||||
'title': config.title,
|
||||
'description': config.description,
|
||||
'site_count': len(sites),
|
||||
'sites': sites,
|
||||
'created_at': config.created_at.isoformat() + 'Z' if config.created_at else None,
|
||||
'updated_at': config.updated_at.isoformat() + 'Z' if config.updated_at else None
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def update_config(self, config_id: int, title: Optional[str], description: Optional[str], site_ids: Optional[List[int]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Update a scan configuration.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID to update
|
||||
title: New title (optional)
|
||||
description: New description (optional)
|
||||
site_ids: New list of site IDs (optional, replaces existing)
|
||||
|
||||
Returns:
|
||||
Updated config dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If config not found or validation fails
|
||||
"""
|
||||
from web.models import ScanConfig, ScanConfigSite, Site
|
||||
|
||||
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||
|
||||
if not config:
|
||||
raise ValueError(f"Config with ID {config_id} not found")
|
||||
|
||||
# Update fields if provided
|
||||
if title is not None:
|
||||
if not title.strip():
|
||||
raise ValueError("Title cannot be empty")
|
||||
config.title = title.strip()
|
||||
|
||||
if description is not None:
|
||||
config.description = description.strip() if description.strip() else None
|
||||
|
||||
# Update sites if provided
|
||||
if site_ids is not None:
|
||||
if len(site_ids) == 0:
|
||||
raise ValueError("At least one site must be selected")
|
||||
|
||||
# Verify all sites exist
|
||||
existing_sites = self.db.query(Site).filter(Site.id.in_(site_ids)).all()
|
||||
if len(existing_sites) != len(site_ids):
|
||||
found_ids = {s.id for s in existing_sites}
|
||||
missing_ids = set(site_ids) - found_ids
|
||||
raise ValueError(f"Sites not found: {missing_ids}")
|
||||
|
||||
# Remove existing associations
|
||||
self.db.query(ScanConfigSite).filter_by(config_id=config_id).delete()
|
||||
|
||||
# Create new associations
|
||||
for site_id in site_ids:
|
||||
assoc = ScanConfigSite(
|
||||
config_id=config_id,
|
||||
site_id=site_id,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
self.db.add(assoc)
|
||||
|
||||
config.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
return self.get_config_by_id(config_id)
|
||||
|
||||
def delete_config(self, config_id: int) -> None:
|
||||
"""
|
||||
Delete a scan configuration from database.
|
||||
|
||||
This will cascade delete associated ScanConfigSite records.
|
||||
Schedules and scans referencing this config will have their
|
||||
config_id set to NULL.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID to delete
|
||||
|
||||
Raises:
|
||||
ValueError: If config not found
|
||||
"""
|
||||
from web.models import ScanConfig
|
||||
|
||||
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||
|
||||
if not config:
|
||||
raise ValueError(f"Config with ID {config_id} not found")
|
||||
|
||||
self.db.delete(config)
|
||||
self.db.commit()
|
||||
|
||||
def add_site_to_config(self, config_id: int, site_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a site to an existing config.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID
|
||||
site_id: Site ID to add
|
||||
|
||||
Returns:
|
||||
Updated config dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If config or site not found, or association already exists
|
||||
"""
|
||||
from web.models import ScanConfig, Site, ScanConfigSite
|
||||
|
||||
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||
if not config:
|
||||
raise ValueError(f"Config with ID {config_id} not found")
|
||||
|
||||
site = self.db.query(Site).filter_by(id=site_id).first()
|
||||
if not site:
|
||||
raise ValueError(f"Site with ID {site_id} not found")
|
||||
|
||||
# Check if association already exists
|
||||
existing = self.db.query(ScanConfigSite).filter_by(
|
||||
config_id=config_id, site_id=site_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise ValueError(f"Site '{site.name}' is already in this config")
|
||||
|
||||
# Create association
|
||||
assoc = ScanConfigSite(
|
||||
config_id=config_id,
|
||||
site_id=site_id,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
self.db.add(assoc)
|
||||
|
||||
config.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
return self.get_config_by_id(config_id)
|
||||
|
||||
def remove_site_from_config(self, config_id: int, site_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Remove a site from a config.
|
||||
|
||||
Args:
|
||||
config_id: Configuration ID
|
||||
site_id: Site ID to remove
|
||||
|
||||
Returns:
|
||||
Updated config dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If config not found, or removing would leave config empty
|
||||
"""
|
||||
from web.models import ScanConfig, ScanConfigSite
|
||||
|
||||
config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||
if not config:
|
||||
raise ValueError(f"Config with ID {config_id} not found")
|
||||
|
||||
# Check if this would leave the config empty
|
||||
current_site_count = len(config.site_associations)
|
||||
if current_site_count <= 1:
|
||||
raise ValueError("Cannot remove last site from config. Delete the config instead.")
|
||||
|
||||
# Remove association
|
||||
deleted = self.db.query(ScanConfigSite).filter_by(
|
||||
config_id=config_id, site_id=site_id
|
||||
).delete()
|
||||
|
||||
if deleted == 0:
|
||||
raise ValueError(f"Site with ID {site_id} is not in this config")
|
||||
|
||||
config.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
return self.get_config_by_id(config_id)
|
||||
|
||||
# ============================================================================
|
||||
# Legacy YAML File Operations (Deprecated)
|
||||
# ============================================================================
|
||||
|
||||
def list_configs_file(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
[DEPRECATED] List all config files with metadata.
|
||||
|
||||
Returns:
|
||||
List of config metadata dictionaries:
|
||||
@@ -175,6 +492,9 @@ class ConfigService:
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
||||
|
||||
# Create inline sites in database (if any)
|
||||
self.create_inline_sites(parsed)
|
||||
|
||||
# Write file
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(content)
|
||||
@@ -266,9 +586,9 @@ class ConfigService:
|
||||
|
||||
return filename, yaml_content
|
||||
|
||||
def update_config(self, filename: str, yaml_content: str) -> None:
|
||||
def update_config_file(self, filename: str, yaml_content: str) -> None:
|
||||
"""
|
||||
Update existing config file with new YAML content.
|
||||
[DEPRECATED] Update existing config file with new YAML content.
|
||||
|
||||
Args:
|
||||
filename: Config filename to update
|
||||
@@ -299,9 +619,9 @@ class ConfigService:
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(yaml_content)
|
||||
|
||||
def delete_config(self, filename: str) -> None:
|
||||
def delete_config_file(self, filename: str) -> None:
|
||||
"""
|
||||
Delete config file and cascade delete any associated schedules.
|
||||
[DEPRECATED] Delete config file and cascade delete any associated schedules.
|
||||
|
||||
When a config is deleted, all schedules using that config (both enabled
|
||||
and disabled) are automatically deleted as well, since they would be
|
||||
@@ -371,12 +691,15 @@ class ConfigService:
|
||||
# Delete file
|
||||
os.remove(filepath)
|
||||
|
||||
def validate_config_content(self, content: Dict) -> Tuple[bool, str]:
|
||||
def validate_config_content(self, content: Dict, check_site_refs: bool = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate parsed YAML config structure.
|
||||
|
||||
Supports both legacy format (inline IPs) and new format (site references or CIDRs).
|
||||
|
||||
Args:
|
||||
content: Parsed YAML config as dict
|
||||
check_site_refs: If True, validates that referenced sites exist in database
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
@@ -408,11 +731,65 @@ class ConfigService:
|
||||
if not isinstance(site, dict):
|
||||
return False, f"Site {i+1} must be a dictionary/object"
|
||||
|
||||
# Check if this is a site reference (new format)
|
||||
if 'site_ref' in site:
|
||||
# Site reference format
|
||||
site_ref = site.get('site_ref')
|
||||
if not isinstance(site_ref, str) or not site_ref.strip():
|
||||
return False, f"Site {i+1} field 'site_ref' must be a non-empty string"
|
||||
|
||||
# Validate site reference exists (if check enabled)
|
||||
if check_site_refs:
|
||||
try:
|
||||
from web.services.site_service import SiteService
|
||||
from flask import current_app
|
||||
|
||||
site_service = SiteService(current_app.db_session)
|
||||
referenced_site = site_service.get_site_by_name(site_ref)
|
||||
if not referenced_site:
|
||||
return False, f"Site {i+1}: Referenced site '{site_ref}' does not exist"
|
||||
except Exception as e:
|
||||
# If we can't check (e.g., outside app context), skip validation
|
||||
pass
|
||||
|
||||
continue # Site reference is valid
|
||||
|
||||
# Check if this is inline site creation with CIDRs (new format)
|
||||
if 'cidrs' in site:
|
||||
# Inline site creation with CIDR format
|
||||
if 'name' not in site:
|
||||
return False, f"Site {i+1} with inline CIDRs missing required field: 'name'"
|
||||
|
||||
cidrs = site.get('cidrs')
|
||||
if not isinstance(cidrs, list):
|
||||
return False, f"Site {i+1} field 'cidrs' must be a list"
|
||||
|
||||
if len(cidrs) == 0:
|
||||
return False, f"Site {i+1} must have at least one CIDR"
|
||||
|
||||
# Validate each CIDR
|
||||
for j, cidr_config in enumerate(cidrs):
|
||||
if not isinstance(cidr_config, dict):
|
||||
return False, f"Site {i+1} CIDR {j+1} must be a dictionary/object"
|
||||
|
||||
if 'cidr' not in cidr_config:
|
||||
return False, f"Site {i+1} CIDR {j+1} missing required field: 'cidr'"
|
||||
|
||||
# Validate CIDR format
|
||||
cidr_str = cidr_config.get('cidr')
|
||||
try:
|
||||
ipaddress.ip_network(cidr_str, strict=False)
|
||||
except ValueError:
|
||||
return False, f"Site {i+1} CIDR {j+1}: Invalid CIDR notation '{cidr_str}'"
|
||||
|
||||
continue # Inline CIDR site is valid
|
||||
|
||||
# Legacy format: inline IPs
|
||||
if 'name' not in site:
|
||||
return False, f"Site {i+1} missing required field: 'name'"
|
||||
|
||||
if 'ips' not in site:
|
||||
return False, f"Site {i+1} missing required field: 'ips'"
|
||||
return False, f"Site {i+1} missing required field: 'ips' (or use 'site_ref' or 'cidrs')"
|
||||
|
||||
if not isinstance(site['ips'], list):
|
||||
return False, f"Site {i+1} field 'ips' must be a list"
|
||||
@@ -550,3 +927,60 @@ class ConfigService:
|
||||
"""
|
||||
filepath = os.path.join(self.configs_dir, filename)
|
||||
return os.path.exists(filepath) and os.path.isfile(filepath)
|
||||
|
||||
def create_inline_sites(self, config_content: Dict) -> None:
|
||||
"""
|
||||
Create sites in the database for inline site definitions in a config.
|
||||
|
||||
This method scans the config for inline site definitions (with CIDRs)
|
||||
and creates them as reusable sites in the database if they don't already exist.
|
||||
|
||||
Args:
|
||||
config_content: Parsed YAML config dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If site creation fails
|
||||
"""
|
||||
try:
|
||||
from web.services.site_service import SiteService
|
||||
from flask import current_app
|
||||
|
||||
site_service = SiteService(current_app.db_session)
|
||||
|
||||
sites = config_content.get('sites', [])
|
||||
|
||||
for site_def in sites:
|
||||
# Skip site references (they already exist)
|
||||
if 'site_ref' in site_def:
|
||||
continue
|
||||
|
||||
# Skip legacy IP-based sites (not creating those as reusable sites)
|
||||
if 'ips' in site_def and 'cidrs' not in site_def:
|
||||
continue
|
||||
|
||||
# Process inline CIDR-based sites
|
||||
if 'cidrs' in site_def:
|
||||
site_name = site_def.get('name')
|
||||
|
||||
# Check if site already exists
|
||||
existing_site = site_service.get_site_by_name(site_name)
|
||||
if existing_site:
|
||||
# Site already exists, skip creation
|
||||
continue
|
||||
|
||||
# Create new site
|
||||
cidrs = site_def.get('cidrs', [])
|
||||
description = f"Auto-created from config '{config_content.get('title', 'Unknown')}'"
|
||||
|
||||
site_service.create_site(
|
||||
name=site_name,
|
||||
description=description,
|
||||
cidrs=cidrs
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# If site creation fails, log but don't block config creation
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
f"Failed to create inline sites from config: {str(e)}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user