stage 1 of doing new cidrs/ site setup
This commit is contained in:
161
app/migrations/versions/006_add_reusable_sites.py
Normal file
161
app/migrations/versions/006_add_reusable_sites.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Add reusable site definitions
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2025-11-19
|
||||||
|
|
||||||
|
This migration introduces reusable site definitions that can be shared across
|
||||||
|
multiple scans. Sites are defined once with CIDR ranges and can be referenced
|
||||||
|
in multiple scan configurations.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic
|
||||||
|
revision = '006'
|
||||||
|
down_revision = '005'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""
|
||||||
|
Create new site tables and migrate existing scan_sites data to the new structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create sites table (master site definitions)
|
||||||
|
op.create_table('sites',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False, comment='Unique site name'),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True, comment='Site description'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Site creation time'),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name', name='uix_site_name')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sites_name'), 'sites', ['name'], unique=True)
|
||||||
|
|
||||||
|
# Create site_cidrs table (CIDR ranges for each site)
|
||||||
|
op.create_table('site_cidrs',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('site_id', sa.Integer(), nullable=False, comment='FK to sites'),
|
||||||
|
sa.Column('cidr', sa.String(length=45), nullable=False, comment='CIDR notation (e.g., 10.0.0.0/24)'),
|
||||||
|
sa.Column('expected_ping', sa.Boolean(), nullable=True, comment='Expected ping response for this CIDR'),
|
||||||
|
sa.Column('expected_tcp_ports', sa.Text(), nullable=True, comment='JSON array of expected TCP ports'),
|
||||||
|
sa.Column('expected_udp_ports', sa.Text(), nullable=True, comment='JSON array of expected UDP ports'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, comment='CIDR creation time'),
|
||||||
|
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('site_id', 'cidr', name='uix_site_cidr')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_site_cidrs_site_id'), 'site_cidrs', ['site_id'], unique=False)
|
||||||
|
|
||||||
|
# Create site_ips table (IP-level overrides within CIDRs)
|
||||||
|
op.create_table('site_ips',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('site_cidr_id', sa.Integer(), nullable=False, comment='FK to site_cidrs'),
|
||||||
|
sa.Column('ip_address', sa.String(length=45), nullable=False, comment='IPv4 or IPv6 address'),
|
||||||
|
sa.Column('expected_ping', sa.Boolean(), nullable=True, comment='Override ping expectation for this IP'),
|
||||||
|
sa.Column('expected_tcp_ports', sa.Text(), nullable=True, comment='JSON array of expected TCP ports (overrides CIDR)'),
|
||||||
|
sa.Column('expected_udp_ports', sa.Text(), nullable=True, comment='JSON array of expected UDP ports (overrides CIDR)'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, comment='IP override creation time'),
|
||||||
|
sa.ForeignKeyConstraint(['site_cidr_id'], ['site_cidrs.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('site_cidr_id', 'ip_address', name='uix_site_cidr_ip')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_site_ips_site_cidr_id'), 'site_ips', ['site_cidr_id'], unique=False)
|
||||||
|
|
||||||
|
# Create scan_site_associations table (many-to-many between scans and sites)
|
||||||
|
op.create_table('scan_site_associations',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
|
||||||
|
sa.Column('site_id', sa.Integer(), nullable=False, comment='FK to sites'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Association creation time'),
|
||||||
|
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('scan_id', 'site_id', name='uix_scan_site')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_scan_site_associations_scan_id'), 'scan_site_associations', ['scan_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_scan_site_associations_site_id'), 'scan_site_associations', ['site_id'], unique=False)
|
||||||
|
|
||||||
|
# Migrate existing data
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Extract unique site names from existing scan_sites and create master Site records
|
||||||
|
# This groups all historical scan sites by name and creates one master site per unique name
|
||||||
|
connection.execute(text("""
|
||||||
|
INSERT INTO sites (name, description, created_at, updated_at)
|
||||||
|
SELECT DISTINCT
|
||||||
|
site_name,
|
||||||
|
'Migrated from scan_sites' as description,
|
||||||
|
datetime('now') as created_at,
|
||||||
|
datetime('now') as updated_at
|
||||||
|
FROM scan_sites
|
||||||
|
WHERE site_name NOT IN (SELECT name FROM sites)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 2. Create scan_site_associations linking scans to their sites
|
||||||
|
# This maintains the historical relationship between scans and the sites they used
|
||||||
|
connection.execute(text("""
|
||||||
|
INSERT INTO scan_site_associations (scan_id, site_id, created_at)
|
||||||
|
SELECT DISTINCT
|
||||||
|
ss.scan_id,
|
||||||
|
s.id as site_id,
|
||||||
|
datetime('now') as created_at
|
||||||
|
FROM scan_sites ss
|
||||||
|
INNER JOIN sites s ON s.name = ss.site_name
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM scan_site_associations ssa
|
||||||
|
WHERE ssa.scan_id = ss.scan_id AND ssa.site_id = s.id
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 3. For each migrated site, create a CIDR entry from the IPs in scan_ips
|
||||||
|
# Since historical data has individual IPs, we'll create /32 CIDRs for each unique IP
|
||||||
|
# This preserves the exact IP addresses while fitting them into the new CIDR-based model
|
||||||
|
connection.execute(text("""
|
||||||
|
INSERT INTO site_cidrs (site_id, cidr, expected_ping, expected_tcp_ports, expected_udp_ports, created_at)
|
||||||
|
SELECT DISTINCT
|
||||||
|
s.id as site_id,
|
||||||
|
si.ip_address || '/32' as cidr,
|
||||||
|
si.ping_expected,
|
||||||
|
'[]' as expected_tcp_ports,
|
||||||
|
'[]' as expected_udp_ports,
|
||||||
|
datetime('now') as created_at
|
||||||
|
FROM scan_ips si
|
||||||
|
INNER JOIN scan_sites ss ON ss.id = si.site_id
|
||||||
|
INNER JOIN sites s ON s.name = ss.site_name
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM site_cidrs sc
|
||||||
|
WHERE sc.site_id = s.id AND sc.cidr = si.ip_address || '/32'
|
||||||
|
)
|
||||||
|
GROUP BY s.id, si.ip_address, si.ping_expected
|
||||||
|
"""))
|
||||||
|
|
||||||
|
print("✓ Migration complete: Reusable sites created from historical scan data")
|
||||||
|
print(f" - Created {connection.execute(text('SELECT COUNT(*) FROM sites')).scalar()} master site(s)")
|
||||||
|
print(f" - Created {connection.execute(text('SELECT COUNT(*) FROM site_cidrs')).scalar()} CIDR range(s)")
|
||||||
|
print(f" - Created {connection.execute(text('SELECT COUNT(*) FROM scan_site_associations')).scalar()} scan-site association(s)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""Remove reusable site tables."""
|
||||||
|
|
||||||
|
# Drop tables in reverse order of creation (respecting foreign keys)
|
||||||
|
op.drop_index(op.f('ix_scan_site_associations_site_id'), table_name='scan_site_associations')
|
||||||
|
op.drop_index(op.f('ix_scan_site_associations_scan_id'), table_name='scan_site_associations')
|
||||||
|
op.drop_table('scan_site_associations')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_site_ips_site_cidr_id'), table_name='site_ips')
|
||||||
|
op.drop_table('site_ips')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_site_cidrs_site_id'), table_name='site_cidrs')
|
||||||
|
op.drop_table('site_cidrs')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_sites_name'), table_name='sites')
|
||||||
|
op.drop_table('sites')
|
||||||
|
|
||||||
|
print("✓ Downgrade complete: Reusable site tables removed")
|
||||||
102
app/migrations/versions/007_configs_to_database.py
Normal file
102
app/migrations/versions/007_configs_to_database.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Add database-stored scan configurations
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006
|
||||||
|
Create Date: 2025-11-19
|
||||||
|
|
||||||
|
This migration introduces database-stored scan configurations to replace YAML
|
||||||
|
config files. Configs reference sites from the sites table, enabling visual
|
||||||
|
config builder and better data management.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic
|
||||||
|
revision = '007'
|
||||||
|
down_revision = '006'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""
|
||||||
|
Create scan_configs and scan_config_sites tables.
|
||||||
|
Add config_id foreign keys to scans and schedules tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create scan_configs table
|
||||||
|
op.create_table('scan_configs',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False, comment='Configuration title'),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True, comment='Configuration description'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Config creation time'),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create scan_config_sites table (many-to-many between configs and sites)
|
||||||
|
op.create_table('scan_config_sites',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('config_id', sa.Integer(), nullable=False, comment='FK to scan_configs'),
|
||||||
|
sa.Column('site_id', sa.Integer(), nullable=False, comment='FK to sites'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Association creation time'),
|
||||||
|
sa.ForeignKeyConstraint(['config_id'], ['scan_configs.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('config_id', 'site_id', name='uix_config_site')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_scan_config_sites_config_id'), 'scan_config_sites', ['config_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_scan_config_sites_site_id'), 'scan_config_sites', ['site_id'], unique=False)
|
||||||
|
|
||||||
|
# Add config_id to scans table
|
||||||
|
with op.batch_alter_table('scans', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('config_id', sa.Integer(), nullable=True, comment='FK to scan_configs table'))
|
||||||
|
batch_op.create_index('ix_scans_config_id', ['config_id'], unique=False)
|
||||||
|
batch_op.create_foreign_key('fk_scans_config_id', 'scan_configs', ['config_id'], ['id'])
|
||||||
|
# Mark config_file as deprecated in comment (already has nullable=True)
|
||||||
|
|
||||||
|
# Add config_id to schedules table and make config_file nullable
|
||||||
|
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('config_id', sa.Integer(), nullable=True, comment='FK to scan_configs table'))
|
||||||
|
batch_op.create_index('ix_schedules_config_id', ['config_id'], unique=False)
|
||||||
|
batch_op.create_foreign_key('fk_schedules_config_id', 'scan_configs', ['config_id'], ['id'])
|
||||||
|
# Make config_file nullable (it was required before)
|
||||||
|
batch_op.alter_column('config_file', existing_type=sa.Text(), nullable=True)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
print("✓ Migration complete: Scan configs tables created")
|
||||||
|
print(" - Created scan_configs table for database-stored configurations")
|
||||||
|
print(" - Created scan_config_sites association table")
|
||||||
|
print(" - Added config_id to scans table")
|
||||||
|
print(" - Added config_id to schedules table")
|
||||||
|
print(" - Existing YAML configs remain in config_file column for backward compatibility")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""Remove scan config tables and columns."""
|
||||||
|
|
||||||
|
# Remove foreign keys and columns from schedules
|
||||||
|
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_schedules_config_id', type_='foreignkey')
|
||||||
|
batch_op.drop_index('ix_schedules_config_id')
|
||||||
|
batch_op.drop_column('config_id')
|
||||||
|
# Restore config_file as required
|
||||||
|
batch_op.alter_column('config_file', existing_type=sa.Text(), nullable=False)
|
||||||
|
|
||||||
|
# Remove foreign keys and columns from scans
|
||||||
|
with op.batch_alter_table('scans', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint('fk_scans_config_id', type_='foreignkey')
|
||||||
|
batch_op.drop_index('ix_scans_config_id')
|
||||||
|
batch_op.drop_column('config_id')
|
||||||
|
|
||||||
|
# Drop tables in reverse order
|
||||||
|
op.drop_index(op.f('ix_scan_config_sites_site_id'), table_name='scan_config_sites')
|
||||||
|
op.drop_index(op.f('ix_scan_config_sites_config_id'), table_name='scan_config_sites')
|
||||||
|
op.drop_table('scan_config_sites')
|
||||||
|
|
||||||
|
op.drop_table('scan_configs')
|
||||||
|
|
||||||
|
print("✓ Downgrade complete: Scan config tables and columns removed")
|
||||||
@@ -29,17 +29,52 @@ sys.stderr.reconfigure(line_buffering=True)
|
|||||||
|
|
||||||
|
|
||||||
class SneakyScanner:
|
class SneakyScanner:
|
||||||
"""Wrapper for masscan to perform network scans based on YAML config"""
|
"""Wrapper for masscan to perform network scans based on YAML config or database config"""
|
||||||
|
|
||||||
def __init__(self, config_path: str, output_dir: str = "/app/output"):
|
def __init__(self, config_path: str = None, config_id: int = None, config_dict: Dict = None, output_dir: str = "/app/output"):
|
||||||
self.config_path = Path(config_path)
|
"""
|
||||||
|
Initialize scanner with configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to YAML config file (legacy)
|
||||||
|
config_id: Database config ID (preferred)
|
||||||
|
config_dict: Config dictionary (for direct use)
|
||||||
|
output_dir: Output directory for scan results
|
||||||
|
|
||||||
|
Note: Provide exactly one of config_path, config_id, or config_dict
|
||||||
|
"""
|
||||||
|
if sum([config_path is not None, config_id is not None, config_dict is not None]) != 1:
|
||||||
|
raise ValueError("Must provide exactly one of: config_path, config_id, or config_dict")
|
||||||
|
|
||||||
|
self.config_path = Path(config_path) if config_path else None
|
||||||
|
self.config_id = config_id
|
||||||
self.output_dir = Path(output_dir)
|
self.output_dir = Path(output_dir)
|
||||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if config_dict:
|
||||||
|
self.config = config_dict
|
||||||
|
# Process sites: resolve references and expand CIDRs
|
||||||
|
if 'sites' in self.config:
|
||||||
|
self.config['sites'] = self._resolve_sites(self.config['sites'])
|
||||||
|
else:
|
||||||
self.config = self._load_config()
|
self.config = self._load_config()
|
||||||
|
|
||||||
self.screenshot_capture = None
|
self.screenshot_capture = None
|
||||||
|
|
||||||
def _load_config(self) -> Dict[str, Any]:
|
def _load_config(self) -> Dict[str, Any]:
|
||||||
"""Load and validate YAML configuration"""
|
"""
|
||||||
|
Load and validate configuration from file or database.
|
||||||
|
|
||||||
|
Supports three formats:
|
||||||
|
1. Legacy: Sites with explicit IP lists
|
||||||
|
2. Site references: Sites referencing database-stored sites
|
||||||
|
3. Inline CIDRs: Sites with CIDR ranges
|
||||||
|
"""
|
||||||
|
# Load from database if config_id provided
|
||||||
|
if self.config_id:
|
||||||
|
return self._load_config_from_database(self.config_id)
|
||||||
|
|
||||||
|
# Load from YAML file
|
||||||
if not self.config_path.exists():
|
if not self.config_path.exists():
|
||||||
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
||||||
|
|
||||||
@@ -51,8 +86,293 @@ class SneakyScanner:
|
|||||||
if not config.get('sites'):
|
if not config.get('sites'):
|
||||||
raise ValueError("Config must include 'sites' field")
|
raise ValueError("Config must include 'sites' field")
|
||||||
|
|
||||||
|
# Process sites: resolve references and expand CIDRs
|
||||||
|
config['sites'] = self._resolve_sites(config['sites'])
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
def _load_config_from_database(self, config_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load configuration from database by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_id: Database config ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Config dictionary with expanded sites
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If config not found or invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular dependencies and allow scanner to work standalone
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from web.models import ScanConfig
|
||||||
|
|
||||||
|
# Create database session
|
||||||
|
db_url = os.environ.get('DATABASE_URL', 'sqlite:////app/data/sneakyscanner.db')
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load config from database
|
||||||
|
db_config = session.query(ScanConfig).filter_by(id=config_id).first()
|
||||||
|
|
||||||
|
if not db_config:
|
||||||
|
raise ValueError(f"Config with ID {config_id} not found in database")
|
||||||
|
|
||||||
|
# Build config dict with site references
|
||||||
|
config = {
|
||||||
|
'title': db_config.title,
|
||||||
|
'sites': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add each site as a site_ref
|
||||||
|
for assoc in db_config.site_associations:
|
||||||
|
site = assoc.site
|
||||||
|
config['sites'].append({
|
||||||
|
'site_ref': site.name
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process sites: resolve references and expand CIDRs
|
||||||
|
config['sites'] = self._resolve_sites(config['sites'])
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
raise ValueError(f"Failed to load config from database (import error): {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to load config from database: {str(e)}")
|
||||||
|
|
||||||
|
def _resolve_sites(self, sites: List[Dict]) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Resolve site references and expand CIDRs to IP lists.
|
||||||
|
|
||||||
|
Converts all site formats into the legacy format (with explicit IPs)
|
||||||
|
for compatibility with the existing scan logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sites: List of site definitions from config
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of sites with expanded IP lists
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
resolved_sites = []
|
||||||
|
|
||||||
|
for site_def in sites:
|
||||||
|
# Handle site references
|
||||||
|
if 'site_ref' in site_def:
|
||||||
|
site_ref = site_def['site_ref']
|
||||||
|
# Load site from database
|
||||||
|
site_data = self._load_site_from_database(site_ref)
|
||||||
|
if site_data:
|
||||||
|
resolved_sites.append(site_data)
|
||||||
|
else:
|
||||||
|
print(f"WARNING: Site reference '{site_ref}' not found in database", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle inline CIDR definitions
|
||||||
|
if 'cidrs' in site_def:
|
||||||
|
site_name = site_def.get('name', 'Unknown Site')
|
||||||
|
expanded_ips = []
|
||||||
|
|
||||||
|
for cidr_def in site_def['cidrs']:
|
||||||
|
cidr = cidr_def['cidr']
|
||||||
|
expected_ping = cidr_def.get('expected_ping', False)
|
||||||
|
expected_tcp_ports = cidr_def.get('expected_tcp_ports', [])
|
||||||
|
expected_udp_ports = cidr_def.get('expected_udp_ports', [])
|
||||||
|
|
||||||
|
# Check if there are IP-level overrides (from database sites)
|
||||||
|
ip_overrides = cidr_def.get('ip_overrides', [])
|
||||||
|
override_map = {
|
||||||
|
override['ip_address']: override
|
||||||
|
for override in ip_overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expand CIDR to IP list
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
ip_list = [str(ip) for ip in network.hosts()]
|
||||||
|
|
||||||
|
# If network has only 1 address (like /32), hosts() returns empty
|
||||||
|
if not ip_list:
|
||||||
|
ip_list = [str(network.network_address)]
|
||||||
|
|
||||||
|
# Create IP config for each IP in the CIDR
|
||||||
|
for ip_address in ip_list:
|
||||||
|
# Check if this IP has an override
|
||||||
|
if ip_address in override_map:
|
||||||
|
override = override_map[ip_address]
|
||||||
|
ip_config = {
|
||||||
|
'address': ip_address,
|
||||||
|
'expected': {
|
||||||
|
'ping': override.get('expected_ping', expected_ping),
|
||||||
|
'tcp_ports': override.get('expected_tcp_ports', expected_tcp_ports),
|
||||||
|
'udp_ports': override.get('expected_udp_ports', expected_udp_ports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Use CIDR-level defaults
|
||||||
|
ip_config = {
|
||||||
|
'address': ip_address,
|
||||||
|
'expected': {
|
||||||
|
'ping': expected_ping,
|
||||||
|
'tcp_ports': expected_tcp_ports,
|
||||||
|
'udp_ports': expected_udp_ports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded_ips.append(ip_config)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"WARNING: Invalid CIDR '{cidr}': {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add expanded site
|
||||||
|
resolved_sites.append({
|
||||||
|
'name': site_name,
|
||||||
|
'ips': expanded_ips
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Legacy format: already has 'ips' list
|
||||||
|
if 'ips' in site_def:
|
||||||
|
resolved_sites.append(site_def)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"WARNING: Site definition missing required fields: {site_def}", file=sys.stderr)
|
||||||
|
|
||||||
|
return resolved_sites
|
||||||
|
|
||||||
|
def _load_site_from_database(self, site_name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Load a site definition from the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_name: Name of the site to load
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site definition dict with expanded IPs, or None if not found
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import database modules
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add parent directory to path if needed
|
||||||
|
parent_dir = str(Path(__file__).parent.parent)
|
||||||
|
if parent_dir not in sys.path:
|
||||||
|
sys.path.insert(0, parent_dir)
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, joinedload
|
||||||
|
from web.models import Site, SiteCIDR
|
||||||
|
|
||||||
|
# Get database URL from environment
|
||||||
|
database_url = os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db')
|
||||||
|
|
||||||
|
# Create engine and session
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Query site with CIDRs and IP overrides
|
||||||
|
site = (
|
||||||
|
session.query(Site)
|
||||||
|
.options(
|
||||||
|
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
|
||||||
|
)
|
||||||
|
.filter(Site.name == site_name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not site:
|
||||||
|
session.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Expand CIDRs to IP list
|
||||||
|
expanded_ips = []
|
||||||
|
|
||||||
|
for cidr_obj in site.cidrs:
|
||||||
|
cidr = cidr_obj.cidr
|
||||||
|
expected_ping = cidr_obj.expected_ping
|
||||||
|
expected_tcp_ports = json.loads(cidr_obj.expected_tcp_ports) if cidr_obj.expected_tcp_ports else []
|
||||||
|
expected_udp_ports = json.loads(cidr_obj.expected_udp_ports) if cidr_obj.expected_udp_ports else []
|
||||||
|
|
||||||
|
# Build IP override map
|
||||||
|
override_map = {}
|
||||||
|
for ip_override in cidr_obj.ips:
|
||||||
|
override_map[ip_override.ip_address] = {
|
||||||
|
'expected_ping': ip_override.expected_ping if ip_override.expected_ping is not None else expected_ping,
|
||||||
|
'expected_tcp_ports': json.loads(ip_override.expected_tcp_ports) if ip_override.expected_tcp_ports else expected_tcp_ports,
|
||||||
|
'expected_udp_ports': json.loads(ip_override.expected_udp_ports) if ip_override.expected_udp_ports else expected_udp_ports
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expand CIDR to IP list
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
ip_list = [str(ip) for ip in network.hosts()]
|
||||||
|
|
||||||
|
if not ip_list:
|
||||||
|
ip_list = [str(network.network_address)]
|
||||||
|
|
||||||
|
for ip_address in ip_list:
|
||||||
|
# Check if this IP has an override
|
||||||
|
if ip_address in override_map:
|
||||||
|
override = override_map[ip_address]
|
||||||
|
ip_config = {
|
||||||
|
'address': ip_address,
|
||||||
|
'expected': {
|
||||||
|
'ping': override['expected_ping'],
|
||||||
|
'tcp_ports': override['expected_tcp_ports'],
|
||||||
|
'udp_ports': override['expected_udp_ports']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Use CIDR-level defaults
|
||||||
|
ip_config = {
|
||||||
|
'address': ip_address,
|
||||||
|
'expected': {
|
||||||
|
'ping': expected_ping if expected_ping is not None else False,
|
||||||
|
'tcp_ports': expected_tcp_ports,
|
||||||
|
'udp_ports': expected_udp_ports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded_ips.append(ip_config)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"WARNING: Invalid CIDR '{cidr}' in site '{site_name}': {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': site.name,
|
||||||
|
'ips': expanded_ips
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to load site '{site_name}' from database: {e}", file=sys.stderr)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
|
|
||||||
def _run_masscan(self, targets: List[str], ports: str, protocol: str) -> List[Dict]:
|
def _run_masscan(self, targets: List[str], ports: str, protocol: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Run masscan and return parsed results
|
Run masscan and return parsed results
|
||||||
@@ -557,6 +877,9 @@ class SneakyScanner:
|
|||||||
Dictionary containing scan results
|
Dictionary containing scan results
|
||||||
"""
|
"""
|
||||||
print(f"Starting scan: {self.config['title']}", flush=True)
|
print(f"Starting scan: {self.config['title']}", flush=True)
|
||||||
|
if self.config_id:
|
||||||
|
print(f"Config ID: {self.config_id}", flush=True)
|
||||||
|
elif self.config_path:
|
||||||
print(f"Config: {self.config_path}", flush=True)
|
print(f"Config: {self.config_path}", flush=True)
|
||||||
|
|
||||||
# Record start time
|
# Record start time
|
||||||
@@ -662,7 +985,8 @@ class SneakyScanner:
|
|||||||
'title': self.config['title'],
|
'title': self.config['title'],
|
||||||
'scan_time': datetime.utcnow().isoformat() + 'Z',
|
'scan_time': datetime.utcnow().isoformat() + 'Z',
|
||||||
'scan_duration': scan_duration,
|
'scan_duration': scan_duration,
|
||||||
'config_file': str(self.config_path),
|
'config_file': str(self.config_path) if self.config_path else None,
|
||||||
|
'config_id': self.config_id,
|
||||||
'sites': []
|
'sites': []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Configs API blueprint.
|
Configs API blueprint.
|
||||||
|
|
||||||
Handles endpoints for managing scan configuration files, including CSV/YAML upload,
|
Handles endpoints for managing scan configurations stored in the database.
|
||||||
template download, and config management.
|
Provides REST API for creating, updating, and deleting configs that reference sites.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import io
|
from flask import Blueprint, jsonify, request, current_app
|
||||||
from flask import Blueprint, jsonify, request, send_file
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
|
|
||||||
from web.auth.decorators import api_auth_required
|
from web.auth.decorators import api_auth_required
|
||||||
from web.services.config_service import ConfigService
|
from web.services.config_service import ConfigService
|
||||||
@@ -17,32 +15,40 @@ bp = Blueprint('configs', __name__)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Database-based Config Endpoints (Primary)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
@bp.route('', methods=['GET'])
|
@bp.route('', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def list_configs():
|
def list_configs():
|
||||||
"""
|
"""
|
||||||
List all config files with metadata.
|
List all scan configurations from database.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with list of configs:
|
JSON response with list of configs:
|
||||||
{
|
{
|
||||||
"configs": [
|
"configs": [
|
||||||
{
|
{
|
||||||
"filename": "prod-scan.yaml",
|
"id": 1,
|
||||||
"title": "Prod Scan",
|
"title": "Production Scan",
|
||||||
"path": "/app/configs/prod-scan.yaml",
|
"description": "Weekly production scan",
|
||||||
"created_at": "2025-11-15T10:30:00Z",
|
"site_count": 3,
|
||||||
"size_bytes": 1234,
|
"sites": [
|
||||||
"used_by_schedules": ["Daily Scan"]
|
{"id": 1, "name": "Production DC"},
|
||||||
|
{"id": 2, "name": "DMZ"}
|
||||||
|
],
|
||||||
|
"created_at": "2025-11-19T10:30:00Z",
|
||||||
|
"updated_at": "2025-11-19T10:30:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config_service = ConfigService()
|
config_service = ConfigService(db_session=current_app.db_session)
|
||||||
configs = config_service.list_configs()
|
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({
|
return jsonify({
|
||||||
'configs': configs
|
'configs': configs
|
||||||
@@ -56,78 +62,38 @@ def list_configs():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<filename>', methods=['GET'])
|
@bp.route('', methods=['POST'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def get_config(filename: str):
|
def create_config():
|
||||||
"""
|
"""
|
||||||
Get config file content and parsed data.
|
Create a new scan configuration in the database.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
JSON with:
|
JSON with:
|
||||||
{
|
{
|
||||||
"title": "My Scan",
|
"title": "Production Scan",
|
||||||
"cidr": "10.0.0.0/24",
|
"description": "Weekly production scan (optional)",
|
||||||
"site_name": "Production" (optional),
|
"site_ids": [1, 2, 3]
|
||||||
"ping_default": false (optional)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON response with created config info:
|
JSON response with created config:
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"filename": "my-scan.yaml",
|
"config": {
|
||||||
"preview": "title: My Scan\n..."
|
"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:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -145,272 +111,192 @@ def create_from_cidr():
|
|||||||
'message': 'Missing required field: title'
|
'message': 'Missing required field: title'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
if 'cidr' not in data:
|
if 'site_ids' not in data:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Bad request',
|
'error': 'Bad request',
|
||||||
'message': 'Missing required field: cidr'
|
'message': 'Missing required field: site_ids'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
title = data['title']
|
title = data['title']
|
||||||
cidr = data['cidr']
|
description = data.get('description', None)
|
||||||
site_name = data.get('site_name', None)
|
site_ids = data['site_ids']
|
||||||
ping_default = data.get('ping_default', False)
|
|
||||||
|
|
||||||
# Validate title
|
if not isinstance(site_ids, list):
|
||||||
if not title or not title.strip():
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Validation error',
|
'error': 'Bad request',
|
||||||
'message': 'Title cannot be empty'
|
'message': 'Field site_ids must be an array'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Create config from CIDR
|
# Create config
|
||||||
config_service = ConfigService()
|
config_service = ConfigService(db_session=current_app.db_session)
|
||||||
filename, yaml_preview = config_service.create_from_cidr(
|
config = config_service.create_config(title, description, site_ids)
|
||||||
title=title,
|
|
||||||
cidr=cidr,
|
|
||||||
site_name=site_name,
|
|
||||||
ping_default=ping_default
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Created config from CIDR {cidr}: {filename}")
|
logger.info(f"Created config: {config['title']} (ID: {config['id']})")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'filename': filename,
|
'config': config
|
||||||
'preview': yaml_preview
|
}), 201
|
||||||
})
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"CIDR validation failed: {str(e)}")
|
logger.warning(f"Config validation failed: {str(e)}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Validation error',
|
'error': 'Validation error',
|
||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
except Exception as e:
|
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({
|
return jsonify({
|
||||||
'error': 'Internal server error',
|
'error': 'Internal server error',
|
||||||
'message': 'An unexpected error occurred'
|
'message': 'An unexpected error occurred'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/upload-yaml', methods=['POST'])
|
@bp.route('/<int:config_id>', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def upload_yaml():
|
def get_config(config_id: int):
|
||||||
"""
|
"""
|
||||||
Upload YAML config file directly.
|
Get a scan configuration by ID.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: Config filename
|
config_id: Configuration ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
YAML file download
|
JSON response with config details:
|
||||||
"""
|
|
||||||
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:
|
|
||||||
{
|
{
|
||||||
"content": "title: My Scan\nsites: ..."
|
"id": 1,
|
||||||
|
"title": "Production Scan",
|
||||||
|
"description": "...",
|
||||||
|
"site_count": 3,
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Production DC",
|
||||||
|
"description": "...",
|
||||||
|
"cidr_count": 5
|
||||||
}
|
}
|
||||||
|
],
|
||||||
Returns:
|
"created_at": "2025-11-19T10:30:00Z",
|
||||||
JSON response with success status:
|
"updated_at": "2025-11-19T10:30:00Z"
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Config updated successfully"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Error responses:
|
Error responses:
|
||||||
- 400: Invalid YAML or config structure
|
- 404: Config not found
|
||||||
- 404: Config file not found
|
|
||||||
- 500: Internal server error
|
- 500: Internal server error
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Sanitize filename
|
config_service = ConfigService(db_session=current_app.db_session)
|
||||||
filename = secure_filename(filename)
|
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(config)
|
||||||
return jsonify({
|
|
||||||
'error': 'Bad request',
|
|
||||||
'message': 'Missing required field: content'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
yaml_content = data['content']
|
except ValueError as e:
|
||||||
|
logger.warning(f"Config not found: {config_id}")
|
||||||
# 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}")
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Not found',
|
'error': 'Not found',
|
||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 404
|
}), 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:
|
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({
|
return jsonify({
|
||||||
'error': 'Internal server error',
|
'error': 'Internal server error',
|
||||||
'message': 'An unexpected error occurred'
|
'message': 'An unexpected error occurred'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/<filename>', methods=['DELETE'])
|
@bp.route('/<int:config_id>', methods=['PUT'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def delete_config(filename: str):
|
def update_config(config_id: int):
|
||||||
"""
|
"""
|
||||||
Delete config file and cascade delete associated schedules.
|
Update an existing scan configuration.
|
||||||
|
|
||||||
When a config is deleted, all schedules using that config (both enabled
|
|
||||||
and disabled) are automatically deleted as well.
|
|
||||||
|
|
||||||
Args:
|
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:
|
Returns:
|
||||||
JSON response with success status:
|
JSON response with success status:
|
||||||
@@ -420,32 +306,155 @@ def delete_config(filename: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
Error responses:
|
Error responses:
|
||||||
- 404: Config file not found
|
- 404: Config not found
|
||||||
- 500: Internal server error
|
- 500: Internal server error
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Sanitize filename
|
config_service = ConfigService(db_session=current_app.db_session)
|
||||||
filename = secure_filename(filename)
|
config_service.delete_config(config_id)
|
||||||
|
|
||||||
config_service = ConfigService()
|
logger.info(f"Deleted config (ID: {config_id})")
|
||||||
config_service.delete_config(filename)
|
|
||||||
|
|
||||||
logger.info(f"Deleted config file: {filename}")
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': 'Config deleted successfully'
|
'message': 'Config deleted successfully'
|
||||||
})
|
})
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Config file not found: {filename}")
|
logger.warning(f"Config not found: {config_id}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Not found',
|
'error': 'Not found',
|
||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
except Exception as e:
|
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({
|
return jsonify({
|
||||||
'error': 'Internal server error',
|
'error': 'Internal server error',
|
||||||
'message': 'An unexpected error occurred'
|
'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
|
||||||
@@ -335,6 +335,7 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
from web.api.settings import bp as settings_bp
|
from web.api.settings import bp as settings_bp
|
||||||
from web.api.stats import bp as stats_bp
|
from web.api.stats import bp as stats_bp
|
||||||
from web.api.configs import bp as configs_bp
|
from web.api.configs import bp as configs_bp
|
||||||
|
from web.api.sites import bp as sites_bp
|
||||||
from web.auth.routes import bp as auth_bp
|
from web.auth.routes import bp as auth_bp
|
||||||
from web.routes.main import bp as main_bp
|
from web.routes.main import bp as main_bp
|
||||||
from web.routes.webhooks import bp as webhooks_bp
|
from web.routes.webhooks import bp as webhooks_bp
|
||||||
@@ -356,6 +357,7 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||||
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||||
app.register_blueprint(configs_bp, url_prefix='/api/configs')
|
app.register_blueprint(configs_bp, url_prefix='/api/configs')
|
||||||
|
app.register_blueprint(sites_bp, url_prefix='/api/sites')
|
||||||
|
|
||||||
app.logger.info("Blueprints registered")
|
app.logger.info("Blueprints registered")
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from web.services.alert_service import AlertService
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def execute_scan(scan_id: int, config_file: str, db_url: str):
|
def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, db_url: str = None):
|
||||||
"""
|
"""
|
||||||
Execute a scan in the background.
|
Execute a scan in the background.
|
||||||
|
|
||||||
@@ -31,9 +31,12 @@ def execute_scan(scan_id: int, config_file: str, db_url: str):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_id: ID of the scan record in database
|
scan_id: ID of the scan record in database
|
||||||
config_file: Path to YAML configuration file
|
config_file: Path to YAML configuration file (legacy, optional)
|
||||||
|
config_id: Database config ID (preferred, optional)
|
||||||
db_url: Database connection URL
|
db_url: Database connection URL
|
||||||
|
|
||||||
|
Note: Provide exactly one of config_file or config_id
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Create new database session for this thread
|
1. Create new database session for this thread
|
||||||
2. Update scan status to 'running'
|
2. Update scan status to 'running'
|
||||||
@@ -42,7 +45,8 @@ def execute_scan(scan_id: int, config_file: str, db_url: str):
|
|||||||
5. Save results to database
|
5. Save results to database
|
||||||
6. Update status to 'completed' or 'failed'
|
6. Update status to 'completed' or 'failed'
|
||||||
"""
|
"""
|
||||||
logger.info(f"Starting background scan execution: scan_id={scan_id}, config={config_file}")
|
config_desc = f"config_id={config_id}" if config_id else f"config_file={config_file}"
|
||||||
|
logger.info(f"Starting background scan execution: scan_id={scan_id}, {config_desc}")
|
||||||
|
|
||||||
# Create new database session for this thread
|
# Create new database session for this thread
|
||||||
engine = create_engine(db_url, echo=False)
|
engine = create_engine(db_url, echo=False)
|
||||||
@@ -61,16 +65,21 @@ def execute_scan(scan_id: int, config_file: str, db_url: str):
|
|||||||
scan.started_at = datetime.utcnow()
|
scan.started_at = datetime.utcnow()
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
logger.info(f"Scan {scan_id}: Initializing scanner with config {config_file}")
|
logger.info(f"Scan {scan_id}: Initializing scanner with {config_desc}")
|
||||||
|
|
||||||
|
# Initialize scanner based on config type
|
||||||
|
if config_id:
|
||||||
|
# Use database config
|
||||||
|
scanner = SneakyScanner(config_id=config_id)
|
||||||
|
else:
|
||||||
|
# Use YAML config file
|
||||||
# Convert config_file to full path if it's just a filename
|
# Convert config_file to full path if it's just a filename
|
||||||
if not config_file.startswith('/'):
|
if not config_file.startswith('/'):
|
||||||
config_path = f'/app/configs/{config_file}'
|
config_path = f'/app/configs/{config_file}'
|
||||||
else:
|
else:
|
||||||
config_path = config_file
|
config_path = config_file
|
||||||
|
|
||||||
# Initialize scanner
|
scanner = SneakyScanner(config_path=config_path)
|
||||||
scanner = SneakyScanner(config_path)
|
|
||||||
|
|
||||||
# Execute scan
|
# Execute scan
|
||||||
logger.info(f"Scan {scan_id}: Running scanner...")
|
logger.info(f"Scan {scan_id}: Running scanner...")
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ class Scan(Base):
|
|||||||
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
|
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
|
||||||
duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
|
duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
|
||||||
status = Column(String(20), nullable=False, default='running', comment="running, completed, failed")
|
status = Column(String(20), nullable=False, default='running', comment="running, completed, failed")
|
||||||
config_file = Column(Text, nullable=True, comment="Path to YAML config used")
|
config_file = Column(Text, nullable=True, comment="Path to YAML config used (deprecated)")
|
||||||
|
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
|
||||||
title = Column(Text, nullable=True, comment="Scan title from config")
|
title = Column(Text, nullable=True, comment="Scan title from config")
|
||||||
json_path = Column(Text, nullable=True, comment="Path to JSON report")
|
json_path = Column(Text, nullable=True, comment="Path to JSON report")
|
||||||
html_path = Column(Text, nullable=True, comment="Path to HTML report")
|
html_path = Column(Text, nullable=True, comment="Path to HTML report")
|
||||||
@@ -68,6 +69,8 @@ class Scan(Base):
|
|||||||
tls_versions = relationship('ScanTLSVersion', back_populates='scan', cascade='all, delete-orphan')
|
tls_versions = relationship('ScanTLSVersion', back_populates='scan', cascade='all, delete-orphan')
|
||||||
alerts = relationship('Alert', back_populates='scan', cascade='all, delete-orphan')
|
alerts = relationship('Alert', back_populates='scan', cascade='all, delete-orphan')
|
||||||
schedule = relationship('Schedule', back_populates='scans')
|
schedule = relationship('Schedule', back_populates='scans')
|
||||||
|
config = relationship('ScanConfig', back_populates='scans')
|
||||||
|
site_associations = relationship('ScanSiteAssociation', back_populates='scan', cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>"
|
return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>"
|
||||||
@@ -242,6 +245,178 @@ class ScanTLSVersion(Base):
|
|||||||
return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>"
|
return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Reusable Site Definition Tables
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class Site(Base):
|
||||||
|
"""
|
||||||
|
Master site definition (reusable across scans).
|
||||||
|
|
||||||
|
Sites represent logical network segments (e.g., "Production DC", "DMZ",
|
||||||
|
"Branch Office") that can be reused across multiple scans. Each site
|
||||||
|
contains one or more CIDR ranges.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'sites'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(255), nullable=False, unique=True, index=True, comment="Unique site name")
|
||||||
|
description = Column(Text, nullable=True, comment="Site description")
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Site creation time")
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
cidrs = relationship('SiteCIDR', back_populates='site', cascade='all, delete-orphan')
|
||||||
|
scan_associations = relationship('ScanSiteAssociation', back_populates='site')
|
||||||
|
config_associations = relationship('ScanConfigSite', back_populates='site')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Site(id={self.id}, name='{self.name}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class SiteCIDR(Base):
|
||||||
|
"""
|
||||||
|
CIDR ranges associated with a site.
|
||||||
|
|
||||||
|
Each site must have at least one CIDR range. CIDR-level expectations
|
||||||
|
(ping, ports) apply to all IPs in the range unless overridden at the IP level.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'site_cidrs'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True)
|
||||||
|
cidr = Column(String(45), nullable=False, comment="CIDR notation (e.g., 10.0.0.0/24)")
|
||||||
|
expected_ping = Column(Boolean, nullable=True, default=False, comment="Expected ping response for this CIDR")
|
||||||
|
expected_tcp_ports = Column(Text, nullable=True, comment="JSON array of expected TCP ports")
|
||||||
|
expected_udp_ports = Column(Text, nullable=True, comment="JSON array of expected UDP ports")
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="CIDR creation time")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
site = relationship('Site', back_populates='cidrs')
|
||||||
|
ips = relationship('SiteIP', back_populates='cidr', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
# Index for efficient CIDR lookups within a site
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('site_id', 'cidr', name='uix_site_cidr'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SiteCIDR(id={self.id}, cidr='{self.cidr}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class SiteIP(Base):
|
||||||
|
"""
|
||||||
|
IP-level expectation overrides within a CIDR range.
|
||||||
|
|
||||||
|
Allows fine-grained control where specific IPs within a CIDR have
|
||||||
|
different expectations than the CIDR-level defaults.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'site_ips'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
site_cidr_id = Column(Integer, ForeignKey('site_cidrs.id'), nullable=False, index=True)
|
||||||
|
ip_address = Column(String(45), nullable=False, comment="IPv4 or IPv6 address")
|
||||||
|
expected_ping = Column(Boolean, nullable=True, comment="Override ping expectation for this IP")
|
||||||
|
expected_tcp_ports = Column(Text, nullable=True, comment="JSON array of expected TCP ports (overrides CIDR)")
|
||||||
|
expected_udp_ports = Column(Text, nullable=True, comment="JSON array of expected UDP ports (overrides CIDR)")
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="IP override creation time")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
cidr = relationship('SiteCIDR', back_populates='ips')
|
||||||
|
|
||||||
|
# Index for efficient IP lookups
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('site_cidr_id', 'ip_address', name='uix_site_cidr_ip'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SiteIP(id={self.id}, ip_address='{self.ip_address}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class ScanSiteAssociation(Base):
|
||||||
|
"""
|
||||||
|
Many-to-many relationship between scans and sites.
|
||||||
|
|
||||||
|
Tracks which sites were included in which scans. This allows sites
|
||||||
|
to be reused across multiple scans.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'scan_site_associations'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
|
||||||
|
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Association creation time")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
scan = relationship('Scan', back_populates='site_associations')
|
||||||
|
site = relationship('Site', back_populates='scan_associations')
|
||||||
|
|
||||||
|
# Index to prevent duplicate associations
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('scan_id', 'site_id', name='uix_scan_site'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ScanSiteAssociation(scan_id={self.scan_id}, site_id={self.site_id})>"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Scan Configuration Tables
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ScanConfig(Base):
|
||||||
|
"""
|
||||||
|
Scan configurations stored in database (replaces YAML files).
|
||||||
|
|
||||||
|
Stores reusable scan configurations that reference sites from the
|
||||||
|
sites table. Configs define what sites to scan together.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'scan_configs'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title = Column(String(255), nullable=False, comment="Configuration title")
|
||||||
|
description = Column(Text, nullable=True, comment="Configuration description")
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Config creation time")
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
site_associations = relationship('ScanConfigSite', back_populates='config', cascade='all, delete-orphan')
|
||||||
|
scans = relationship('Scan', back_populates='config')
|
||||||
|
schedules = relationship('Schedule', back_populates='config')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ScanConfig(id={self.id}, title='{self.title}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class ScanConfigSite(Base):
|
||||||
|
"""
|
||||||
|
Many-to-many relationship between scan configs and sites.
|
||||||
|
|
||||||
|
Links scan configurations to the sites they should scan. A config
|
||||||
|
can reference multiple sites, and sites can be used in multiple configs.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'scan_config_sites'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=False, index=True)
|
||||||
|
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Association creation time")
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
config = relationship('ScanConfig', back_populates='site_associations')
|
||||||
|
site = relationship('Site', back_populates='config_associations')
|
||||||
|
|
||||||
|
# Index to prevent duplicate associations
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('config_id', 'site_id', name='uix_config_site'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ScanConfigSite(config_id={self.config_id}, site_id={self.site_id})>"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Scheduling & Notifications Tables
|
# Scheduling & Notifications Tables
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -258,7 +433,8 @@ class Schedule(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
|
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
|
||||||
config_file = Column(Text, nullable=False, comment="Path to YAML config")
|
config_file = Column(Text, nullable=True, comment="Path to YAML config (deprecated)")
|
||||||
|
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
|
||||||
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
|
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
|
||||||
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")
|
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")
|
||||||
last_run = Column(DateTime, nullable=True, comment="Last execution time")
|
last_run = Column(DateTime, nullable=True, comment="Last execution time")
|
||||||
@@ -268,6 +444,7 @@ class Schedule(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
scans = relationship('Scan', back_populates='schedule')
|
scans = relationship('Scan', back_populates='schedule')
|
||||||
|
config = relationship('ScanConfig', back_populates='schedules')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Schedule(id={self.id}, name='{self.name}', enabled={self.enabled})>"
|
return f"<Schedule(id={self.id}, name='{self.name}', enabled={self.enabled})>"
|
||||||
|
|||||||
@@ -164,6 +164,18 @@ def edit_schedule(schedule_id):
|
|||||||
return render_template('schedule_edit.html', schedule_id=schedule_id)
|
return render_template('schedule_edit.html', schedule_id=schedule_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/sites')
|
||||||
|
@login_required
|
||||||
|
def sites():
|
||||||
|
"""
|
||||||
|
Sites management page - manage reusable site definitions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered sites template
|
||||||
|
"""
|
||||||
|
return render_template('sites.html')
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/configs')
|
@bp.route('/configs')
|
||||||
@login_required
|
@login_required
|
||||||
def configs():
|
def configs():
|
||||||
|
|||||||
@@ -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,
|
This service handles all operations related to scan configurations,
|
||||||
including creation, validation, listing, and deletion.
|
both database-stored (primary) and file-based (deprecated).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -13,26 +13,343 @@ from typing import Dict, List, Tuple, Any, Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
class ConfigService:
|
class ConfigService:
|
||||||
"""Business logic for config management"""
|
"""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.
|
Initialize the config service.
|
||||||
|
|
||||||
Args:
|
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
|
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)
|
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:
|
Returns:
|
||||||
List of config metadata dictionaries:
|
List of config metadata dictionaries:
|
||||||
@@ -175,6 +492,9 @@ class ConfigService:
|
|||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise ValueError(f"Invalid config structure: {error_msg}")
|
raise ValueError(f"Invalid config structure: {error_msg}")
|
||||||
|
|
||||||
|
# Create inline sites in database (if any)
|
||||||
|
self.create_inline_sites(parsed)
|
||||||
|
|
||||||
# Write file
|
# Write file
|
||||||
with open(filepath, 'w') as f:
|
with open(filepath, 'w') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
@@ -266,9 +586,9 @@ class ConfigService:
|
|||||||
|
|
||||||
return filename, yaml_content
|
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:
|
Args:
|
||||||
filename: Config filename to update
|
filename: Config filename to update
|
||||||
@@ -299,9 +619,9 @@ class ConfigService:
|
|||||||
with open(filepath, 'w') as f:
|
with open(filepath, 'w') as f:
|
||||||
f.write(yaml_content)
|
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
|
When a config is deleted, all schedules using that config (both enabled
|
||||||
and disabled) are automatically deleted as well, since they would be
|
and disabled) are automatically deleted as well, since they would be
|
||||||
@@ -371,12 +691,15 @@ class ConfigService:
|
|||||||
# Delete file
|
# Delete file
|
||||||
os.remove(filepath)
|
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.
|
Validate parsed YAML config structure.
|
||||||
|
|
||||||
|
Supports both legacy format (inline IPs) and new format (site references or CIDRs).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: Parsed YAML config as dict
|
content: Parsed YAML config as dict
|
||||||
|
check_site_refs: If True, validates that referenced sites exist in database
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (is_valid, error_message)
|
Tuple of (is_valid, error_message)
|
||||||
@@ -408,11 +731,65 @@ class ConfigService:
|
|||||||
if not isinstance(site, dict):
|
if not isinstance(site, dict):
|
||||||
return False, f"Site {i+1} must be a dictionary/object"
|
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:
|
if 'name' not in site:
|
||||||
return False, f"Site {i+1} missing required field: 'name'"
|
return False, f"Site {i+1} missing required field: 'name'"
|
||||||
|
|
||||||
if 'ips' not in site:
|
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):
|
if not isinstance(site['ips'], list):
|
||||||
return False, f"Site {i+1} field 'ips' must be a 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)
|
filepath = os.path.join(self.configs_dir, filename)
|
||||||
return os.path.exists(filepath) and os.path.isfile(filepath)
|
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)}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
|
|
||||||
from web.models import (
|
from web.models import (
|
||||||
Scan, ScanSite, ScanIP, ScanPort, ScanService as ScanServiceModel,
|
Scan, ScanSite, ScanIP, ScanPort, ScanService as ScanServiceModel,
|
||||||
ScanCertificate, ScanTLSVersion
|
ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation
|
||||||
)
|
)
|
||||||
from web.utils.pagination import paginate, PaginatedResult
|
from web.utils.pagination import paginate, PaginatedResult
|
||||||
from web.utils.validators import validate_config_file, validate_scan_status
|
from web.utils.validators import validate_config_file, validate_scan_status
|
||||||
@@ -41,8 +41,9 @@ class ScanService:
|
|||||||
"""
|
"""
|
||||||
self.db = db_session
|
self.db = db_session
|
||||||
|
|
||||||
def trigger_scan(self, config_file: str, triggered_by: str = 'manual',
|
def trigger_scan(self, config_file: str = None, config_id: int = None,
|
||||||
schedule_id: Optional[int] = None, scheduler=None) -> int:
|
triggered_by: str = 'manual', schedule_id: Optional[int] = None,
|
||||||
|
scheduler=None) -> int:
|
||||||
"""
|
"""
|
||||||
Trigger a new scan.
|
Trigger a new scan.
|
||||||
|
|
||||||
@@ -50,7 +51,8 @@ class ScanService:
|
|||||||
queues the scan for background execution.
|
queues the scan for background execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_file: Path to YAML configuration file
|
config_file: Path to YAML configuration file (legacy, optional)
|
||||||
|
config_id: Database config ID (preferred, optional)
|
||||||
triggered_by: Source that triggered scan (manual, scheduled, api)
|
triggered_by: Source that triggered scan (manual, scheduled, api)
|
||||||
schedule_id: Optional schedule ID if triggered by schedule
|
schedule_id: Optional schedule ID if triggered by schedule
|
||||||
scheduler: Optional SchedulerService instance for queuing background jobs
|
scheduler: Optional SchedulerService instance for queuing background jobs
|
||||||
@@ -59,8 +61,57 @@ class ScanService:
|
|||||||
Scan ID of the created scan
|
Scan ID of the created scan
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If config file is invalid
|
ValueError: If config is invalid or both/neither config_file and config_id provided
|
||||||
"""
|
"""
|
||||||
|
# Validate that exactly one config source is provided
|
||||||
|
if not (bool(config_file) ^ bool(config_id)):
|
||||||
|
raise ValueError("Must provide exactly one of config_file or config_id")
|
||||||
|
|
||||||
|
# Handle database config
|
||||||
|
if config_id:
|
||||||
|
from web.models import ScanConfig
|
||||||
|
|
||||||
|
# Validate config exists
|
||||||
|
db_config = self.db.query(ScanConfig).filter_by(id=config_id).first()
|
||||||
|
if not db_config:
|
||||||
|
raise ValueError(f"Config with ID {config_id} not found")
|
||||||
|
|
||||||
|
# Create scan record with config_id
|
||||||
|
scan = Scan(
|
||||||
|
timestamp=datetime.utcnow(),
|
||||||
|
status='running',
|
||||||
|
config_id=config_id,
|
||||||
|
title=db_config.title,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(scan)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(scan)
|
||||||
|
|
||||||
|
logger.info(f"Scan {scan.id} triggered via {triggered_by} with config_id={config_id}")
|
||||||
|
|
||||||
|
# Queue background job if scheduler provided
|
||||||
|
if scheduler:
|
||||||
|
try:
|
||||||
|
job_id = scheduler.queue_scan(scan.id, config_id=config_id)
|
||||||
|
logger.info(f"Scan {scan.id} queued for background execution (job_id={job_id})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to queue scan {scan.id}: {str(e)}")
|
||||||
|
# Mark scan as failed if job queuing fails
|
||||||
|
scan.status = 'failed'
|
||||||
|
scan.error_message = f"Failed to queue background job: {str(e)}"
|
||||||
|
self.db.commit()
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
logger.warning(f"Scan {scan.id} created but not queued (no scheduler provided)")
|
||||||
|
|
||||||
|
return scan.id
|
||||||
|
|
||||||
|
# Handle legacy YAML config file
|
||||||
|
else:
|
||||||
# Validate config file
|
# Validate config file
|
||||||
is_valid, error_msg = validate_config_file(config_file)
|
is_valid, error_msg = validate_config_file(config_file)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
@@ -97,7 +148,7 @@ class ScanService:
|
|||||||
# Queue background job if scheduler provided
|
# Queue background job if scheduler provided
|
||||||
if scheduler:
|
if scheduler:
|
||||||
try:
|
try:
|
||||||
job_id = scheduler.queue_scan(scan.id, config_file)
|
job_id = scheduler.queue_scan(scan.id, config_file=config_file)
|
||||||
logger.info(f"Scan {scan.id} queued for background execution (job_id={job_id})")
|
logger.info(f"Scan {scan.id} queued for background execution (job_id={job_id})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to queue scan {scan.id}: {str(e)}")
|
logger.error(f"Failed to queue scan {scan.id}: {str(e)}")
|
||||||
@@ -366,6 +417,34 @@ class ScanService:
|
|||||||
self.db.add(site)
|
self.db.add(site)
|
||||||
self.db.flush() # Get site.id for foreign key
|
self.db.flush() # Get site.id for foreign key
|
||||||
|
|
||||||
|
# Create ScanSiteAssociation if this site exists in the database
|
||||||
|
# This links the scan to reusable site definitions
|
||||||
|
master_site = (
|
||||||
|
self.db.query(Site)
|
||||||
|
.filter(Site.name == site_data['name'])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if master_site:
|
||||||
|
# Check if association already exists (avoid duplicates)
|
||||||
|
existing_assoc = (
|
||||||
|
self.db.query(ScanSiteAssociation)
|
||||||
|
.filter(
|
||||||
|
ScanSiteAssociation.scan_id == scan_obj.id,
|
||||||
|
ScanSiteAssociation.site_id == master_site.id
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing_assoc:
|
||||||
|
assoc = ScanSiteAssociation(
|
||||||
|
scan_id=scan_obj.id,
|
||||||
|
site_id=master_site.id,
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
self.db.add(assoc)
|
||||||
|
logger.debug(f"Created association between scan {scan_obj.id} and site '{master_site.name}' (id={master_site.id})")
|
||||||
|
|
||||||
# Process each IP in this site
|
# Process each IP in this site
|
||||||
for ip_data in site_data.get('ips', []):
|
for ip_data in site_data.get('ips', []):
|
||||||
# Create ScanIP record
|
# Create ScanIP record
|
||||||
|
|||||||
@@ -149,13 +149,16 @@ class SchedulerService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
|
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
def queue_scan(self, scan_id: int, config_file: str) -> str:
|
def queue_scan(self, scan_id: int, config_file: str = None, config_id: int = None) -> str:
|
||||||
"""
|
"""
|
||||||
Queue a scan for immediate background execution.
|
Queue a scan for immediate background execution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
scan_id: Database ID of the scan
|
scan_id: Database ID of the scan
|
||||||
config_file: Path to YAML configuration file
|
config_file: Path to YAML configuration file (legacy, optional)
|
||||||
|
config_id: Database config ID (preferred, optional)
|
||||||
|
|
||||||
|
Note: Provide exactly one of config_file or config_id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Job ID from APScheduler
|
Job ID from APScheduler
|
||||||
@@ -169,7 +172,7 @@ class SchedulerService:
|
|||||||
# Add job to run immediately
|
# Add job to run immediately
|
||||||
job = self.scheduler.add_job(
|
job = self.scheduler.add_job(
|
||||||
func=execute_scan,
|
func=execute_scan,
|
||||||
args=[scan_id, config_file, self.db_url],
|
kwargs={'scan_id': scan_id, 'config_file': config_file, 'config_id': config_id, 'db_url': self.db_url},
|
||||||
id=f'scan_{scan_id}',
|
id=f'scan_{scan_id}',
|
||||||
name=f'Scan {scan_id}',
|
name=f'Scan {scan_id}',
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
|
|||||||
531
app/web/services/site_service.py
Normal file
531
app/web/services/site_service.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
"""
|
||||||
|
Site service for managing reusable site definitions.
|
||||||
|
|
||||||
|
This service handles the business logic for creating, updating, and managing
|
||||||
|
sites with their associated CIDR ranges and IP-level overrides.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from web.models import (
|
||||||
|
Site, SiteCIDR, SiteIP, ScanSiteAssociation
|
||||||
|
)
|
||||||
|
from web.utils.pagination import paginate, PaginatedResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SiteService:
|
||||||
|
"""
|
||||||
|
Service for managing reusable site definitions.
|
||||||
|
|
||||||
|
Handles site lifecycle: creation, updates, deletion (with safety checks),
|
||||||
|
CIDR management, and IP-level overrides.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_session: Session):
|
||||||
|
"""
|
||||||
|
Initialize site service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_session: SQLAlchemy database session
|
||||||
|
"""
|
||||||
|
self.db = db_session
|
||||||
|
|
||||||
|
def create_site(self, name: str, description: Optional[str] = None,
|
||||||
|
cidrs: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new site with optional CIDR ranges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique site name
|
||||||
|
description: Optional site description
|
||||||
|
cidrs: List of CIDR definitions with format:
|
||||||
|
[{"cidr": "10.0.0.0/24", "expected_ping": true,
|
||||||
|
"expected_tcp_ports": [22, 80], "expected_udp_ports": [53]}]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with created site data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site name already exists or validation fails
|
||||||
|
"""
|
||||||
|
# Validate site name is unique
|
||||||
|
existing = self.db.query(Site).filter(Site.name == name).first()
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Site with name '{name}' already exists")
|
||||||
|
|
||||||
|
# Validate we have at least one CIDR if provided
|
||||||
|
if cidrs is not None and len(cidrs) == 0:
|
||||||
|
raise ValueError("Site must have at least one CIDR range")
|
||||||
|
|
||||||
|
# Create site
|
||||||
|
site = Site(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(site)
|
||||||
|
self.db.flush() # Get site.id without committing
|
||||||
|
|
||||||
|
# Add CIDRs if provided
|
||||||
|
if cidrs:
|
||||||
|
for cidr_data in cidrs:
|
||||||
|
self._add_cidr_to_site(site, cidr_data)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(site)
|
||||||
|
|
||||||
|
logger.info(f"Created site '{name}' (id={site.id}) with {len(cidrs or [])} CIDR(s)")
|
||||||
|
|
||||||
|
return self._site_to_dict(site)
|
||||||
|
|
||||||
|
def update_site(self, site_id: int, name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Update site metadata (name and/or description).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID to update
|
||||||
|
name: New site name (must be unique)
|
||||||
|
description: New description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with updated site data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found or name already exists
|
||||||
|
"""
|
||||||
|
site = self.db.query(Site).filter(Site.id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise ValueError(f"Site with id {site_id} not found")
|
||||||
|
|
||||||
|
# Update name if provided
|
||||||
|
if name is not None and name != site.name:
|
||||||
|
# Check uniqueness
|
||||||
|
existing = self.db.query(Site).filter(
|
||||||
|
Site.name == name,
|
||||||
|
Site.id != site_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Site with name '{name}' already exists")
|
||||||
|
site.name = name
|
||||||
|
|
||||||
|
# Update description if provided
|
||||||
|
if description is not None:
|
||||||
|
site.description = description
|
||||||
|
|
||||||
|
site.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(site)
|
||||||
|
|
||||||
|
logger.info(f"Updated site {site_id} ('{site.name}')")
|
||||||
|
|
||||||
|
return self._site_to_dict(site)
|
||||||
|
|
||||||
|
def delete_site(self, site_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Delete a site.
|
||||||
|
|
||||||
|
Prevents deletion if the site is used in any scan (per user requirement).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID to delete
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found or is used in scans
|
||||||
|
"""
|
||||||
|
site = self.db.query(Site).filter(Site.id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise ValueError(f"Site with id {site_id} not found")
|
||||||
|
|
||||||
|
# Check if site is used in any scans
|
||||||
|
usage_count = (
|
||||||
|
self.db.query(func.count(ScanSiteAssociation.id))
|
||||||
|
.filter(ScanSiteAssociation.site_id == site_id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
if usage_count > 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot delete site '{site.name}': it is used in {usage_count} scan(s). "
|
||||||
|
f"Sites that have been used in scans cannot be deleted."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Safe to delete
|
||||||
|
self.db.delete(site)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted site {site_id} ('{site.name}')")
|
||||||
|
|
||||||
|
def get_site(self, site_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get site details with all CIDRs and IP overrides.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with site data, or None if not found
|
||||||
|
"""
|
||||||
|
site = (
|
||||||
|
self.db.query(Site)
|
||||||
|
.options(
|
||||||
|
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
|
||||||
|
)
|
||||||
|
.filter(Site.id == site_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not site:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._site_to_dict(site)
|
||||||
|
|
||||||
|
def get_site_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get site details by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Site name to retrieve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with site data, or None if not found
|
||||||
|
"""
|
||||||
|
site = (
|
||||||
|
self.db.query(Site)
|
||||||
|
.options(
|
||||||
|
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
|
||||||
|
)
|
||||||
|
.filter(Site.name == name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not site:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._site_to_dict(site)
|
||||||
|
|
||||||
|
def list_sites(self, page: int = 1, per_page: int = 20) -> PaginatedResult:
|
||||||
|
"""
|
||||||
|
List all sites with pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
per_page: Number of items per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResult with site data
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
self.db.query(Site)
|
||||||
|
.options(joinedload(Site.cidrs))
|
||||||
|
.order_by(Site.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return paginate(query, page, per_page, self._site_to_dict)
|
||||||
|
|
||||||
|
def list_all_sites(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
List all sites without pagination (for dropdowns, etc.).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of site dictionaries
|
||||||
|
"""
|
||||||
|
sites = (
|
||||||
|
self.db.query(Site)
|
||||||
|
.options(joinedload(Site.cidrs))
|
||||||
|
.order_by(Site.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [self._site_to_dict(site) for site in sites]
|
||||||
|
|
||||||
|
def add_cidr(self, site_id: int, cidr: str, expected_ping: Optional[bool] = None,
|
||||||
|
expected_tcp_ports: Optional[List[int]] = None,
|
||||||
|
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add a CIDR range to a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID
|
||||||
|
cidr: CIDR notation (e.g., "10.0.0.0/24")
|
||||||
|
expected_ping: Expected ping response for IPs in this CIDR
|
||||||
|
expected_tcp_ports: List of expected TCP ports
|
||||||
|
expected_udp_ports: List of expected UDP ports
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with CIDR data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found, CIDR is invalid, or already exists
|
||||||
|
"""
|
||||||
|
site = self.db.query(Site).filter(Site.id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise ValueError(f"Site with id {site_id} not found")
|
||||||
|
|
||||||
|
# Validate CIDR format
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
|
||||||
|
|
||||||
|
# Check for duplicate CIDR
|
||||||
|
existing = (
|
||||||
|
self.db.query(SiteCIDR)
|
||||||
|
.filter(SiteCIDR.site_id == site_id, SiteCIDR.cidr == cidr)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"CIDR '{cidr}' already exists for this site")
|
||||||
|
|
||||||
|
# Create CIDR
|
||||||
|
cidr_obj = SiteCIDR(
|
||||||
|
site_id=site_id,
|
||||||
|
cidr=cidr,
|
||||||
|
expected_ping=expected_ping,
|
||||||
|
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
|
||||||
|
expected_udp_ports=json.dumps(expected_udp_ports or []),
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(cidr_obj)
|
||||||
|
site.updated_at = datetime.utcnow()
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(cidr_obj)
|
||||||
|
|
||||||
|
logger.info(f"Added CIDR '{cidr}' to site {site_id} ('{site.name}')")
|
||||||
|
|
||||||
|
return self._cidr_to_dict(cidr_obj)
|
||||||
|
|
||||||
|
def remove_cidr(self, site_id: int, cidr_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Remove a CIDR range from a site.
|
||||||
|
|
||||||
|
Prevents removal if it's the last CIDR (sites must have at least one CIDR).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID
|
||||||
|
cidr_id: CIDR ID to remove
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If CIDR not found or it's the last CIDR
|
||||||
|
"""
|
||||||
|
site = self.db.query(Site).filter(Site.id == site_id).first()
|
||||||
|
if not site:
|
||||||
|
raise ValueError(f"Site with id {site_id} not found")
|
||||||
|
|
||||||
|
cidr = (
|
||||||
|
self.db.query(SiteCIDR)
|
||||||
|
.filter(SiteCIDR.id == cidr_id, SiteCIDR.site_id == site_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not cidr:
|
||||||
|
raise ValueError(f"CIDR with id {cidr_id} not found for site {site_id}")
|
||||||
|
|
||||||
|
# Check if this is the last CIDR
|
||||||
|
cidr_count = (
|
||||||
|
self.db.query(func.count(SiteCIDR.id))
|
||||||
|
.filter(SiteCIDR.site_id == site_id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
if cidr_count <= 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot remove CIDR '{cidr.cidr}': site must have at least one CIDR range"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.delete(cidr)
|
||||||
|
site.updated_at = datetime.utcnow()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Removed CIDR '{cidr.cidr}' from site {site_id} ('{site.name}')")
|
||||||
|
|
||||||
|
def add_ip_override(self, cidr_id: int, ip_address: str,
|
||||||
|
expected_ping: Optional[bool] = None,
|
||||||
|
expected_tcp_ports: Optional[List[int]] = None,
|
||||||
|
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add an IP-level expectation override within a CIDR.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cidr_id: CIDR ID
|
||||||
|
ip_address: IP address to override
|
||||||
|
expected_ping: Override ping expectation
|
||||||
|
expected_tcp_ports: Override TCP ports expectation
|
||||||
|
expected_udp_ports: Override UDP ports expectation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with IP override data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If CIDR not found, IP is invalid, or not in CIDR range
|
||||||
|
"""
|
||||||
|
cidr = self.db.query(SiteCIDR).filter(SiteCIDR.id == cidr_id).first()
|
||||||
|
if not cidr:
|
||||||
|
raise ValueError(f"CIDR with id {cidr_id} not found")
|
||||||
|
|
||||||
|
# Validate IP format
|
||||||
|
try:
|
||||||
|
ip_obj = ipaddress.ip_address(ip_address)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Invalid IP address '{ip_address}': {str(e)}")
|
||||||
|
|
||||||
|
# Validate IP is within CIDR range
|
||||||
|
network = ipaddress.ip_network(cidr.cidr, strict=False)
|
||||||
|
if ip_obj not in network:
|
||||||
|
raise ValueError(f"IP address '{ip_address}' is not within CIDR '{cidr.cidr}'")
|
||||||
|
|
||||||
|
# Check for duplicate
|
||||||
|
existing = (
|
||||||
|
self.db.query(SiteIP)
|
||||||
|
.filter(SiteIP.site_cidr_id == cidr_id, SiteIP.ip_address == ip_address)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"IP override for '{ip_address}' already exists in this CIDR")
|
||||||
|
|
||||||
|
# Create IP override
|
||||||
|
ip_override = SiteIP(
|
||||||
|
site_cidr_id=cidr_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
expected_ping=expected_ping,
|
||||||
|
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
|
||||||
|
expected_udp_ports=json.dumps(expected_udp_ports or []),
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(ip_override)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(ip_override)
|
||||||
|
|
||||||
|
logger.info(f"Added IP override '{ip_address}' to CIDR {cidr_id} ('{cidr.cidr}')")
|
||||||
|
|
||||||
|
return self._ip_override_to_dict(ip_override)
|
||||||
|
|
||||||
|
def remove_ip_override(self, cidr_id: int, ip_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Remove an IP-level override.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cidr_id: CIDR ID
|
||||||
|
ip_id: IP override ID to remove
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If IP override not found
|
||||||
|
"""
|
||||||
|
ip_override = (
|
||||||
|
self.db.query(SiteIP)
|
||||||
|
.filter(SiteIP.id == ip_id, SiteIP.site_cidr_id == cidr_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ip_override:
|
||||||
|
raise ValueError(f"IP override with id {ip_id} not found for CIDR {cidr_id}")
|
||||||
|
|
||||||
|
ip_address = ip_override.ip_address
|
||||||
|
self.db.delete(ip_override)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Removed IP override '{ip_address}' from CIDR {cidr_id}")
|
||||||
|
|
||||||
|
def get_scan_usage(self, site_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get list of scans that use this site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of scan dictionaries
|
||||||
|
"""
|
||||||
|
from web.models import Scan # Import here to avoid circular dependency
|
||||||
|
|
||||||
|
associations = (
|
||||||
|
self.db.query(ScanSiteAssociation)
|
||||||
|
.options(joinedload(ScanSiteAssociation.scan))
|
||||||
|
.filter(ScanSiteAssociation.site_id == site_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': assoc.scan.id,
|
||||||
|
'title': assoc.scan.title,
|
||||||
|
'timestamp': assoc.scan.timestamp.isoformat() if assoc.scan.timestamp else None,
|
||||||
|
'status': assoc.scan.status
|
||||||
|
}
|
||||||
|
for assoc in associations
|
||||||
|
]
|
||||||
|
|
||||||
|
# Private helper methods
|
||||||
|
|
||||||
|
def _add_cidr_to_site(self, site: Site, cidr_data: Dict[str, Any]) -> SiteCIDR:
|
||||||
|
"""Helper to add CIDR during site creation."""
|
||||||
|
cidr = cidr_data.get('cidr')
|
||||||
|
if not cidr:
|
||||||
|
raise ValueError("CIDR 'cidr' field is required")
|
||||||
|
|
||||||
|
# Validate CIDR format
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
|
||||||
|
|
||||||
|
cidr_obj = SiteCIDR(
|
||||||
|
site_id=site.id,
|
||||||
|
cidr=cidr,
|
||||||
|
expected_ping=cidr_data.get('expected_ping'),
|
||||||
|
expected_tcp_ports=json.dumps(cidr_data.get('expected_tcp_ports', [])),
|
||||||
|
expected_udp_ports=json.dumps(cidr_data.get('expected_udp_ports', [])),
|
||||||
|
created_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(cidr_obj)
|
||||||
|
return cidr_obj
|
||||||
|
|
||||||
|
def _site_to_dict(self, site: Site) -> Dict[str, Any]:
|
||||||
|
"""Convert Site model to dictionary."""
|
||||||
|
return {
|
||||||
|
'id': site.id,
|
||||||
|
'name': site.name,
|
||||||
|
'description': site.description,
|
||||||
|
'created_at': site.created_at.isoformat() if site.created_at else None,
|
||||||
|
'updated_at': site.updated_at.isoformat() if site.updated_at else None,
|
||||||
|
'cidrs': [self._cidr_to_dict(cidr) for cidr in site.cidrs] if hasattr(site, 'cidrs') else []
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cidr_to_dict(self, cidr: SiteCIDR) -> Dict[str, Any]:
|
||||||
|
"""Convert SiteCIDR model to dictionary."""
|
||||||
|
return {
|
||||||
|
'id': cidr.id,
|
||||||
|
'site_id': cidr.site_id,
|
||||||
|
'cidr': cidr.cidr,
|
||||||
|
'expected_ping': cidr.expected_ping,
|
||||||
|
'expected_tcp_ports': json.loads(cidr.expected_tcp_ports) if cidr.expected_tcp_ports else [],
|
||||||
|
'expected_udp_ports': json.loads(cidr.expected_udp_ports) if cidr.expected_udp_ports else [],
|
||||||
|
'created_at': cidr.created_at.isoformat() if cidr.created_at else None,
|
||||||
|
'ip_overrides': [self._ip_override_to_dict(ip) for ip in cidr.ips] if hasattr(cidr, 'ips') else []
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ip_override_to_dict(self, ip: SiteIP) -> Dict[str, Any]:
|
||||||
|
"""Convert SiteIP model to dictionary."""
|
||||||
|
return {
|
||||||
|
'id': ip.id,
|
||||||
|
'site_cidr_id': ip.site_cidr_id,
|
||||||
|
'ip_address': ip.ip_address,
|
||||||
|
'expected_ping': ip.expected_ping,
|
||||||
|
'expected_tcp_ports': json.loads(ip.expected_tcp_ports) if ip.expected_tcp_ports else [],
|
||||||
|
'expected_udp_ports': json.loads(ip.expected_udp_ports) if ip.expected_udp_ports else [],
|
||||||
|
'created_at': ip.created_at.isoformat() if ip.created_at else None
|
||||||
|
}
|
||||||
@@ -53,6 +53,10 @@
|
|||||||
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
|
||||||
href="{{ url_for('main.schedules') }}">Schedules</a>
|
href="{{ url_for('main.schedules') }}">Schedules</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'main.sites' %}active{% endif %}"
|
||||||
|
href="{{ url_for('main.sites') }}">Sites</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
|
||||||
href="{{ url_for('main.configs') }}">Configs</a>
|
href="{{ url_for('main.configs') }}">Configs</a>
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Configuration Files - SneakyScanner{% endblock %}
|
{% block title %}Scan Configurations - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
{% block extra_styles %}
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 style="color: #60a5fa;">Configuration Files</h1>
|
<h1 style="color: #60a5fa;">Scan Configurations</h1>
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary">
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createConfigModal">
|
||||||
<i class="bi bi-plus-circle"></i> Create New Config
|
<i class="bi bi-plus-circle"></i> Create New Config
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,14 +26,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="configs-in-use">-</div>
|
<div class="stat-value" id="total-sites-used">-</div>
|
||||||
<div class="stat-label">In Use by Schedules</div>
|
<div class="stat-label">Total Sites Referenced</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="total-size">-</div>
|
<div class="stat-value" id="recent-updates">-</div>
|
||||||
<div class="stat-label">Total Size</div>
|
<div class="stat-label">Updated This Week</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,11 +64,10 @@
|
|||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Filename</th>
|
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Created</th>
|
<th>Description</th>
|
||||||
<th>Size</th>
|
<th>Sites</th>
|
||||||
<th>Used By</th>
|
<th>Updated</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -82,12 +77,12 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div id="empty-state" style="display: none;" class="text-center py-5">
|
<div id="empty-state" style="display: none;" class="text-center py-5">
|
||||||
<i class="bi bi-file-earmark-text" style="font-size: 3rem; color: #64748b;"></i>
|
<i class="bi bi-gear" style="font-size: 3rem; color: #64748b;"></i>
|
||||||
<h5 class="mt-3 text-muted">No configuration files</h5>
|
<h5 class="mt-3 text-muted">No configurations defined</h5>
|
||||||
<p class="text-muted">Create your first config to define scan targets</p>
|
<p class="text-muted">Create your first scan configuration</p>
|
||||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary mt-2">
|
<button class="btn btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createConfigModal">
|
||||||
<i class="bi bi-plus-circle"></i> Create Config
|
<i class="bi bi-plus-circle"></i> Create Config
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,23 +90,141 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Create Config Modal -->
|
||||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
<div class="modal fade" id="createConfigModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
<h5 class="modal-title" style="color: #f87171;">
|
<h5 class="modal-title" style="color: #60a5fa;">
|
||||||
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
|
<i class="bi bi-plus-circle"></i> Create New Configuration
|
||||||
</h5>
|
</h5>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p style="color: #e2e8f0;">Are you sure you want to delete the config file:</p>
|
<form id="create-config-form">
|
||||||
<p style="color: #60a5fa; font-weight: bold;" id="delete-config-name"></p>
|
<div class="mb-3">
|
||||||
<p style="color: #fbbf24;" id="delete-warning-schedules" style="display: none;">
|
<label for="config-title" class="form-label">Title <span class="text-danger">*</span></label>
|
||||||
<i class="bi bi-exclamation-circle"></i>
|
<input type="text" class="form-control" id="config-title" required
|
||||||
This config is used by schedules and cannot be deleted.
|
placeholder="e.g., Production Weekly Scan">
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="config-description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="config-description" rows="3"
|
||||||
|
placeholder="Optional description of this configuration"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Sites <span class="text-danger">*</span></label>
|
||||||
|
<div id="sites-loading-modal" class="text-center py-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<span class="ms-2 text-muted">Loading available sites...</span>
|
||||||
|
</div>
|
||||||
|
<div id="sites-list" style="display: none;">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Select at least one site to include in this configuration</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger" id="create-config-error" style="display: none;">
|
||||||
|
<span id="create-config-error-message"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="create-config-btn">
|
||||||
|
<i class="bi bi-check-circle"></i> Create Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Config Modal -->
|
||||||
|
<div class="modal fade" id="editConfigModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-pencil"></i> Edit Configuration
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="edit-config-form">
|
||||||
|
<input type="hidden" id="edit-config-id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit-config-title" class="form-label">Title <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="edit-config-title" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit-config-description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="edit-config-description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Sites <span class="text-danger">*</span></label>
|
||||||
|
<div id="edit-sites-list">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger" id="edit-config-error" style="display: none;">
|
||||||
|
<span id="edit-config-error-message"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="edit-config-btn">
|
||||||
|
<i class="bi bi-check-circle"></i> Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Config Modal -->
|
||||||
|
<div class="modal fade" id="viewConfigModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-eye"></i> Configuration Details
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="view-config-content">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteConfigModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #ef4444;">
|
||||||
|
<i class="bi bi-trash"></i> Delete Configuration
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete configuration <strong id="delete-config-name"></strong>?</p>
|
||||||
|
<p class="text-warning"><i class="bi bi-exclamation-triangle"></i> This action cannot be undone.</p>
|
||||||
|
<input type="hidden" id="delete-config-id">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -123,76 +236,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View Config Modal -->
|
|
||||||
<div class="modal fade" id="viewModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
|
||||||
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
|
||||||
<h5 class="modal-title" style="color: #60a5fa;">
|
|
||||||
<i class="bi bi-file-earmark-code"></i> Config File Details
|
|
||||||
</h5>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<h6 style="color: #94a3b8;">Filename: <span id="view-filename" style="color: #e2e8f0;"></span></h6>
|
|
||||||
<h6 class="mt-3" style="color: #94a3b8;">Content:</h6>
|
|
||||||
<pre style="background-color: #0f172a; border: 1px solid #334155; padding: 15px; border-radius: 5px; max-height: 400px; overflow-y: auto;"><code id="view-content" style="color: #e2e8f0;"></code></pre>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
||||||
<a id="download-link" href="#" class="btn btn-primary">
|
|
||||||
<i class="bi bi-download"></i> Download
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
// Global variables
|
// Global state
|
||||||
let configsData = [];
|
let allConfigs = [];
|
||||||
let selectedConfigForDeletion = null;
|
let allSites = [];
|
||||||
|
|
||||||
// Format file size
|
// Load data on page load
|
||||||
function formatFileSize(bytes) {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (bytes === 0) return '0 Bytes';
|
loadSites();
|
||||||
const k = 1024;
|
loadConfigs();
|
||||||
const sizes = ['Bytes', 'KB', 'MB'];
|
});
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format date
|
// Load all sites
|
||||||
function formatDate(timestamp) {
|
async function loadSites() {
|
||||||
if (!timestamp) return 'Unknown';
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load configs from API
|
|
||||||
async function loadConfigs() {
|
|
||||||
try {
|
try {
|
||||||
document.getElementById('configs-loading').style.display = 'block';
|
const response = await fetch('/api/sites?all=true');
|
||||||
document.getElementById('configs-error').style.display = 'none';
|
if (!response.ok) throw new Error('Failed to load sites');
|
||||||
document.getElementById('configs-content').style.display = 'none';
|
|
||||||
|
|
||||||
const response = await fetch('/api/configs');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
configsData = data.configs || [];
|
allSites = data.sites || [];
|
||||||
|
|
||||||
|
renderSitesCheckboxes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading sites:', error);
|
||||||
|
document.getElementById('sites-loading-modal').innerHTML =
|
||||||
|
'<div class="alert alert-danger">Failed to load sites</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render sites checkboxes
|
||||||
|
function renderSitesCheckboxes(selectedIds = [], isEditMode = false) {
|
||||||
|
const container = isEditMode ? document.getElementById('edit-sites-list') : document.getElementById('sites-list');
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (allSites.length === 0) {
|
||||||
|
const message = '<div class="alert alert-info">No sites available. <a href="/sites">Create a site first</a>.</div>';
|
||||||
|
container.innerHTML = message;
|
||||||
|
if (!isEditMode) {
|
||||||
|
document.getElementById('sites-loading-modal').style.display = 'none';
|
||||||
|
container.style.display = 'block';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = isEditMode ? 'edit-site' : 'site';
|
||||||
|
const checkboxClass = isEditMode ? 'edit-site-checkbox' : 'site-checkbox';
|
||||||
|
|
||||||
|
let html = '<div style="max-height: 300px; overflow-y: auto;">';
|
||||||
|
allSites.forEach(site => {
|
||||||
|
const isChecked = selectedIds.includes(site.id);
|
||||||
|
html += `
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input ${checkboxClass}" type="checkbox" value="${site.id}"
|
||||||
|
id="${prefix}-${site.id}" ${isChecked ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="${prefix}-${site.id}">
|
||||||
|
${escapeHtml(site.name)}
|
||||||
|
<small class="text-muted">(${site.cidr_count || 0} CIDR${site.cidr_count !== 1 ? 's' : ''})</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
if (!isEditMode) {
|
||||||
|
document.getElementById('sites-loading-modal').style.display = 'none';
|
||||||
|
container.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all configs
|
||||||
|
async function loadConfigs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/configs');
|
||||||
|
if (!response.ok) throw new Error('Failed to load configs');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
allConfigs = data.configs || [];
|
||||||
|
|
||||||
|
renderConfigs();
|
||||||
updateStats();
|
updateStats();
|
||||||
renderConfigs(configsData);
|
|
||||||
|
|
||||||
document.getElementById('configs-loading').style.display = 'none';
|
document.getElementById('configs-loading').style.display = 'none';
|
||||||
document.getElementById('configs-content').style.display = 'block';
|
document.getElementById('configs-content').style.display = 'block';
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading configs:', error);
|
console.error('Error loading configs:', error);
|
||||||
document.getElementById('configs-loading').style.display = 'none';
|
document.getElementById('configs-loading').style.display = 'none';
|
||||||
@@ -201,177 +332,249 @@ async function loadConfigs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update summary stats
|
|
||||||
function updateStats() {
|
|
||||||
const totalConfigs = configsData.length;
|
|
||||||
const configsInUse = configsData.filter(c => c.used_by_schedules && c.used_by_schedules.length > 0).length;
|
|
||||||
const totalSize = configsData.reduce((sum, c) => sum + (c.size_bytes || 0), 0);
|
|
||||||
|
|
||||||
document.getElementById('total-configs').textContent = totalConfigs;
|
|
||||||
document.getElementById('configs-in-use').textContent = configsInUse;
|
|
||||||
document.getElementById('total-size').textContent = formatFileSize(totalSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render configs table
|
// Render configs table
|
||||||
function renderConfigs(configs) {
|
function renderConfigs(filter = '') {
|
||||||
const tbody = document.getElementById('configs-tbody');
|
const tbody = document.getElementById('configs-tbody');
|
||||||
const emptyState = document.getElementById('empty-state');
|
const emptyState = document.getElementById('empty-state');
|
||||||
|
|
||||||
if (configs.length === 0) {
|
const filteredConfigs = filter
|
||||||
|
? allConfigs.filter(c =>
|
||||||
|
c.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
(c.description && c.description.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
)
|
||||||
|
: allConfigs;
|
||||||
|
|
||||||
|
if (filteredConfigs.length === 0) {
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
emptyState.style.display = 'block';
|
emptyState.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
|
tbody.innerHTML = filteredConfigs.map(config => `
|
||||||
tbody.innerHTML = configs.map(config => {
|
|
||||||
const usedByBadge = config.used_by_schedules && config.used_by_schedules.length > 0
|
|
||||||
? `<span class="badge bg-info" title="${config.used_by_schedules.join(', ')}">${config.used_by_schedules.length} schedule(s)</span>`
|
|
||||||
: '<span class="badge bg-secondary">Not used</span>';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>${config.filename}</code></td>
|
<td><strong>${escapeHtml(config.title)}</strong></td>
|
||||||
<td>${config.title || config.filename}</td>
|
<td>${config.description ? escapeHtml(config.description) : '<span class="text-muted">-</span>'}</td>
|
||||||
<td>${formatDate(config.created_at)}</td>
|
|
||||||
<td>${formatFileSize(config.size_bytes || 0)}</td>
|
|
||||||
<td>${usedByBadge}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<span class="badge bg-primary">${config.site_count} site${config.site_count !== 1 ? 's' : ''}</span>
|
||||||
<button class="btn btn-outline-primary" onclick="viewConfig('${config.filename}')" title="View">
|
</td>
|
||||||
|
<td>${formatDate(config.updated_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-info" onclick="viewConfig(${config.id})" title="View">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
<a href="/configs/edit/${config.filename}" class="btn btn-outline-info" title="Edit">
|
<button class="btn btn-sm btn-warning" onclick="editConfig(${config.id})" title="Edit">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</button>
|
||||||
<a href="/api/configs/${config.filename}/download" class="btn btn-outline-success" title="Download">
|
<button class="btn btn-sm btn-danger" onclick="deleteConfig(${config.id}, '${escapeHtml(config.title).replace(/'/g, "\\'")}');" title="Delete">
|
||||||
<i class="bi bi-download"></i>
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-outline-danger" onclick="confirmDelete('${config.filename}', ${config.used_by_schedules.length > 0})" title="Delete">
|
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`).join('');
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// View config details
|
// Update stats
|
||||||
async function viewConfig(filename) {
|
function updateStats() {
|
||||||
try {
|
document.getElementById('total-configs').textContent = allConfigs.length;
|
||||||
const response = await fetch(`/api/configs/${filename}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load config: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const uniqueSites = new Set();
|
||||||
|
allConfigs.forEach(c => c.sites.forEach(s => uniqueSites.add(s.id)));
|
||||||
|
document.getElementById('total-sites-used').textContent = uniqueSites.size;
|
||||||
|
|
||||||
document.getElementById('view-filename').textContent = data.filename;
|
const oneWeekAgo = new Date();
|
||||||
document.getElementById('view-content').textContent = data.content;
|
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||||
document.getElementById('download-link').href = `/api/configs/${filename}/download`;
|
const recentUpdates = allConfigs.filter(c => new Date(c.updated_at) > oneWeekAgo).length;
|
||||||
|
document.getElementById('recent-updates').textContent = recentUpdates;
|
||||||
new bootstrap.Modal(document.getElementById('viewModal')).show();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error viewing config:', error);
|
|
||||||
alert(`Error: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm delete
|
// Search functionality
|
||||||
function confirmDelete(filename, isInUse) {
|
|
||||||
selectedConfigForDeletion = filename;
|
|
||||||
document.getElementById('delete-config-name').textContent = filename;
|
|
||||||
|
|
||||||
const warningDiv = document.getElementById('delete-warning-schedules');
|
|
||||||
const deleteBtn = document.getElementById('confirm-delete-btn');
|
|
||||||
|
|
||||||
if (isInUse) {
|
|
||||||
warningDiv.style.display = 'block';
|
|
||||||
deleteBtn.disabled = true;
|
|
||||||
deleteBtn.classList.add('disabled');
|
|
||||||
} else {
|
|
||||||
warningDiv.style.display = 'none';
|
|
||||||
deleteBtn.disabled = false;
|
|
||||||
deleteBtn.classList.remove('disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete config
|
|
||||||
async function deleteConfig() {
|
|
||||||
if (!selectedConfigForDeletion) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/configs/${selectedConfigForDeletion}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide modal
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
|
|
||||||
|
|
||||||
// Reload configs
|
|
||||||
await loadConfigs();
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
showAlert('success', `Config "${selectedConfigForDeletion}" deleted successfully`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting config:', error);
|
|
||||||
showAlert('danger', `Error deleting config: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show alert
|
|
||||||
function showAlert(type, message) {
|
|
||||||
const alertHtml = `
|
|
||||||
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
|
|
||||||
${message}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const container = document.querySelector('.container-fluid');
|
|
||||||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
|
||||||
|
|
||||||
// Auto-dismiss after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
const alert = container.querySelector('.alert');
|
|
||||||
if (alert) {
|
|
||||||
bootstrap.Alert.getInstance(alert)?.close();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search filter
|
|
||||||
document.getElementById('search-input').addEventListener('input', function(e) {
|
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||||
const searchTerm = e.target.value.toLowerCase();
|
renderConfigs(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
if (!searchTerm) {
|
// Create config
|
||||||
renderConfigs(configsData);
|
document.getElementById('create-config-btn').addEventListener('click', async function() {
|
||||||
|
const title = document.getElementById('config-title').value.trim();
|
||||||
|
const description = document.getElementById('config-description').value.trim();
|
||||||
|
const siteCheckboxes = document.querySelectorAll('.site-checkbox:checked');
|
||||||
|
const siteIds = Array.from(siteCheckboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
showError('create-config-error', 'Title is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = configsData.filter(config =>
|
if (siteIds.length === 0) {
|
||||||
config.filename.toLowerCase().includes(searchTerm) ||
|
showError('create-config-error', 'At least one site must be selected');
|
||||||
(config.title && config.title.toLowerCase().includes(searchTerm))
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
renderConfigs(filtered);
|
try {
|
||||||
|
const response = await fetch('/api/configs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, description: description || null, site_ids: siteIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Failed to create config');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and reload
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('createConfigModal')).hide();
|
||||||
|
document.getElementById('create-config-form').reset();
|
||||||
|
renderSitesCheckboxes(); // Reset checkboxes
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (error) {
|
||||||
|
showError('create-config-error', error.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup delete button
|
// View config
|
||||||
document.getElementById('confirm-delete-btn').addEventListener('click', deleteConfig);
|
async function viewConfig(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/configs/${id}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load config');
|
||||||
|
|
||||||
// Load configs on page load
|
const config = await response.json();
|
||||||
document.addEventListener('DOMContentLoaded', loadConfigs);
|
|
||||||
|
let html = `
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Title:</strong> ${escapeHtml(config.title)}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Description:</strong> ${config.description ? escapeHtml(config.description) : '<span class="text-muted">None</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Sites (${config.site_count}):</strong>
|
||||||
|
<ul class="mt-2">
|
||||||
|
${config.sites.map(site => `
|
||||||
|
<li>${escapeHtml(site.name)} <small class="text-muted">(${site.cidr_count} CIDR${site.cidr_count !== 1 ? 's' : ''})</small></li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Created:</strong> ${formatDate(config.created_at)}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>Last Updated:</strong> ${formatDate(config.updated_at)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('view-config-content').innerHTML = html;
|
||||||
|
new bootstrap.Modal(document.getElementById('viewConfigModal')).show();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error loading config: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit config
|
||||||
|
async function editConfig(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/configs/${id}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load config');
|
||||||
|
|
||||||
|
const config = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('edit-config-id').value = config.id;
|
||||||
|
document.getElementById('edit-config-title').value = config.title;
|
||||||
|
document.getElementById('edit-config-description').value = config.description || '';
|
||||||
|
|
||||||
|
const selectedIds = config.sites.map(s => s.id);
|
||||||
|
renderSitesCheckboxes(selectedIds, true); // true = isEditMode
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('editConfigModal')).show();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error loading config: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save edited config
|
||||||
|
document.getElementById('edit-config-btn').addEventListener('click', async function() {
|
||||||
|
const id = document.getElementById('edit-config-id').value;
|
||||||
|
const title = document.getElementById('edit-config-title').value.trim();
|
||||||
|
const description = document.getElementById('edit-config-description').value.trim();
|
||||||
|
const siteCheckboxes = document.querySelectorAll('.edit-site-checkbox:checked');
|
||||||
|
const siteIds = Array.from(siteCheckboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
showError('edit-config-error', 'Title is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteIds.length === 0) {
|
||||||
|
showError('edit-config-error', 'At least one site must be selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/configs/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, description: description || null, site_ids: siteIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Failed to update config');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and reload
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('editConfigModal')).hide();
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (error) {
|
||||||
|
showError('edit-config-error', error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete config
|
||||||
|
function deleteConfig(id, name) {
|
||||||
|
document.getElementById('delete-config-id').value = id;
|
||||||
|
document.getElementById('delete-config-name').textContent = name;
|
||||||
|
new bootstrap.Modal(document.getElementById('deleteConfigModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm delete
|
||||||
|
document.getElementById('confirm-delete-btn').addEventListener('click', async function() {
|
||||||
|
const id = document.getElementById('delete-config-id').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/configs/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Failed to delete config');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and reload
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('deleteConfigModal')).hide();
|
||||||
|
await loadConfigs();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting config: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function showError(elementId, message) {
|
||||||
|
const errorEl = document.getElementById(elementId);
|
||||||
|
const messageEl = document.getElementById(elementId + '-message');
|
||||||
|
messageEl.textContent = message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
775
app/web/templates/sites.html
Normal file
775
app/web/templates/sites.html
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sites - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: #60a5fa;">Site Management</h1>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createSiteModal">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create New Site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="total-sites">-</div>
|
||||||
|
<div class="stat-label">Total Sites</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="total-cidrs">-</div>
|
||||||
|
<div class="stat-label">Total CIDRs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="sites-in-use">-</div>
|
||||||
|
<div class="stat-label">Used in Scans</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">All Sites</h5>
|
||||||
|
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
|
||||||
|
placeholder="Search sites...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="sites-loading" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-muted">Loading sites...</p>
|
||||||
|
</div>
|
||||||
|
<div id="sites-error" style="display: none;" class="alert alert-danger">
|
||||||
|
<strong>Error:</strong> <span id="error-message"></span>
|
||||||
|
</div>
|
||||||
|
<div id="sites-content" style="display: none;">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>CIDRs</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sites-tbody">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="empty-state" style="display: none;" class="text-center py-5">
|
||||||
|
<i class="bi bi-globe" style="font-size: 3rem; color: #64748b;"></i>
|
||||||
|
<h5 class="mt-3 text-muted">No sites defined</h5>
|
||||||
|
<p class="text-muted">Create your first site to group CIDR ranges</p>
|
||||||
|
<button class="btn btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createSiteModal">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Site Modal -->
|
||||||
|
<div class="modal fade" id="createSiteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create New Site
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="create-site-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site-name" class="form-label" style="color: #e2e8f0;">Site Name *</label>
|
||||||
|
<input type="text" class="form-control" id="site-name" required
|
||||||
|
placeholder="e.g., Production Web Servers">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="site-description" class="form-label" style="color: #e2e8f0;">Description</label>
|
||||||
|
<textarea class="form-control" id="site-description" rows="2"
|
||||||
|
placeholder="Optional description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" style="color: #e2e8f0;">CIDR Ranges *</label>
|
||||||
|
<div id="cidrs-container">
|
||||||
|
<!-- CIDR inputs will be added here -->
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="addCidrInput()">
|
||||||
|
<i class="bi bi-plus"></i> Add CIDR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createSite()">
|
||||||
|
<i class="bi bi-check-circle"></i> Create Site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Site Modal -->
|
||||||
|
<div class="modal fade" id="viewSiteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-globe"></i> <span id="view-site-name"></span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<h6 style="color: #94a3b8;">Description:</h6>
|
||||||
|
<p id="view-site-description" style="color: #e2e8f0;"></p>
|
||||||
|
|
||||||
|
<h6 class="mt-3" style="color: #94a3b8;">CIDR Ranges:</h6>
|
||||||
|
<div id="view-site-cidrs" class="table-responsive">
|
||||||
|
<!-- Will be populated -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="mt-3" style="color: #94a3b8;">Usage:</h6>
|
||||||
|
<div id="view-site-usage">
|
||||||
|
<p style="color: #94a3b8;"><i class="bi bi-hourglass"></i> Loading usage...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Site Modal -->
|
||||||
|
<div class="modal fade" id="editSiteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #60a5fa;">
|
||||||
|
<i class="bi bi-pencil"></i> Edit Site
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="edit-site-id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit-site-name" class="form-label">Site Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" id="edit-site-name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit-site-description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="edit-site-description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i> To edit CIDRs and IP ranges, please delete and recreate the site.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-danger" id="edit-site-error" style="display: none;">
|
||||||
|
<span id="edit-site-error-message"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="save-edit-site-btn">
|
||||||
|
<i class="bi bi-check-circle"></i> Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
|
||||||
|
<div class="modal-header" style="border-bottom: 1px solid #334155;">
|
||||||
|
<h5 class="modal-title" style="color: #f87171;">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="color: #e2e8f0;">Are you sure you want to delete the site:</p>
|
||||||
|
<p style="color: #60a5fa; font-weight: bold;" id="delete-site-name"></p>
|
||||||
|
<p style="color: #fbbf24;" id="delete-warning" style="display: none;">
|
||||||
|
<i class="bi bi-exclamation-circle"></i>
|
||||||
|
<span id="delete-warning-message"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
|
||||||
|
<i class="bi bi-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Global variables
|
||||||
|
let sitesData = [];
|
||||||
|
let selectedSiteForDeletion = null;
|
||||||
|
let cidrInputCounter = 0;
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return 'Unknown';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CIDR input field
|
||||||
|
function addCidrInput(cidr = '', expectedPing = false, expectedTcpPorts = [], expectedUdpPorts = []) {
|
||||||
|
const container = document.getElementById('cidrs-container');
|
||||||
|
const id = cidrInputCounter++;
|
||||||
|
|
||||||
|
const cidrHtml = `
|
||||||
|
<div class="cidr-input-group mb-2 p-3" style="background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;" id="cidr-${id}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">CIDR *</label>
|
||||||
|
<input type="text" class="form-control form-control-sm cidr-value" placeholder="e.g., 10.0.0.0/24" value="${cidr}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expect Ping</label>
|
||||||
|
<select class="form-select form-select-sm cidr-ping">
|
||||||
|
<option value="">Default (No)</option>
|
||||||
|
<option value="true" ${expectedPing ? 'selected' : ''}>Yes</option>
|
||||||
|
<option value="false" ${expectedPing === false ? 'selected' : ''}>No</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Actions</label>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger w-100" onclick="removeCidrInput(${id})">
|
||||||
|
<i class="bi bi-trash"></i> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expected TCP Ports</label>
|
||||||
|
<input type="text" class="form-control form-control-sm cidr-tcp-ports" placeholder="e.g., 22,80,443" value="${expectedTcpPorts.join(',')}">
|
||||||
|
<small style="color: #64748b;">Comma-separated port numbers</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" style="color: #94a3b8; font-size: 0.875rem;">Expected UDP Ports</label>
|
||||||
|
<input type="text" class="form-control form-control-sm cidr-udp-ports" placeholder="e.g., 53,123" value="${expectedUdpPorts.join(',')}">
|
||||||
|
<small style="color: #64748b;">Comma-separated port numbers</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.insertAdjacentHTML('beforeend', cidrHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove CIDR input field
|
||||||
|
function removeCidrInput(id) {
|
||||||
|
const element = document.getElementById(`cidr-${id}`);
|
||||||
|
if (element) {
|
||||||
|
element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse port list from comma-separated string
|
||||||
|
function parsePortList(portString) {
|
||||||
|
if (!portString || !portString.trim()) return [];
|
||||||
|
return portString.split(',')
|
||||||
|
.map(p => parseInt(p.trim()))
|
||||||
|
.filter(p => !isNaN(p) && p > 0 && p <= 65535);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load sites from API
|
||||||
|
async function loadSites() {
|
||||||
|
try {
|
||||||
|
document.getElementById('sites-loading').style.display = 'block';
|
||||||
|
document.getElementById('sites-error').style.display = 'none';
|
||||||
|
document.getElementById('sites-content').style.display = 'none';
|
||||||
|
|
||||||
|
const response = await fetch('/api/sites?all=true');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
sitesData = data.sites || [];
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
renderSites(sitesData);
|
||||||
|
|
||||||
|
document.getElementById('sites-loading').style.display = 'none';
|
||||||
|
document.getElementById('sites-content').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading sites:', error);
|
||||||
|
document.getElementById('sites-loading').style.display = 'none';
|
||||||
|
document.getElementById('sites-error').style.display = 'block';
|
||||||
|
document.getElementById('error-message').textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary stats
|
||||||
|
function updateStats() {
|
||||||
|
const totalSites = sitesData.length;
|
||||||
|
const totalCidrs = sitesData.reduce((sum, site) => sum + (site.cidrs?.length || 0), 0);
|
||||||
|
|
||||||
|
document.getElementById('total-sites').textContent = totalSites;
|
||||||
|
document.getElementById('total-cidrs').textContent = totalCidrs;
|
||||||
|
document.getElementById('sites-in-use').textContent = '-'; // Will be updated async
|
||||||
|
|
||||||
|
// Count sites in use (async)
|
||||||
|
let sitesInUse = 0;
|
||||||
|
sitesData.forEach(async (site) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sites/${site.id}/usage`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.count > 0) {
|
||||||
|
sitesInUse++;
|
||||||
|
document.getElementById('sites-in-use').textContent = sitesInUse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error checking site usage:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render sites table
|
||||||
|
function renderSites(sites) {
|
||||||
|
const tbody = document.getElementById('sites-tbody');
|
||||||
|
const emptyState = document.getElementById('empty-state');
|
||||||
|
|
||||||
|
if (sites.length === 0) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
|
||||||
|
tbody.innerHTML = sites.map(site => {
|
||||||
|
const cidrCount = site.cidrs?.length || 0;
|
||||||
|
const cidrBadge = cidrCount > 0
|
||||||
|
? `<span class="badge bg-info">${cidrCount} CIDR${cidrCount !== 1 ? 's' : ''}</span>`
|
||||||
|
: '<span class="badge bg-secondary">No CIDRs</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong style="color: #60a5fa;">${site.name}</strong></td>
|
||||||
|
<td style="color: #94a3b8;">${site.description || '<em>No description</em>'}</td>
|
||||||
|
<td>${cidrBadge}</td>
|
||||||
|
<td>${formatDate(site.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button class="btn btn-outline-primary" onclick="viewSite(${site.id})" title="View">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-warning" onclick="editSite(${site.id})" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="confirmDelete(${site.id}, '${site.name}')" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new site
|
||||||
|
async function createSite() {
|
||||||
|
try {
|
||||||
|
const name = document.getElementById('site-name').value.trim();
|
||||||
|
const description = document.getElementById('site-description').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showAlert('warning', 'Site name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect CIDRs
|
||||||
|
const cidrGroups = document.querySelectorAll('.cidr-input-group');
|
||||||
|
const cidrs = [];
|
||||||
|
|
||||||
|
cidrGroups.forEach(group => {
|
||||||
|
const cidrValue = group.querySelector('.cidr-value').value.trim();
|
||||||
|
if (!cidrValue) return;
|
||||||
|
|
||||||
|
const pingValue = group.querySelector('.cidr-ping').value;
|
||||||
|
const tcpPorts = parsePortList(group.querySelector('.cidr-tcp-ports').value);
|
||||||
|
const udpPorts = parsePortList(group.querySelector('.cidr-udp-ports').value);
|
||||||
|
|
||||||
|
cidrs.push({
|
||||||
|
cidr: cidrValue,
|
||||||
|
expected_ping: pingValue === 'true' ? true : (pingValue === 'false' ? false : null),
|
||||||
|
expected_tcp_ports: tcpPorts,
|
||||||
|
expected_udp_ports: udpPorts
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cidrs.length === 0) {
|
||||||
|
showAlert('warning', 'At least one CIDR is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/sites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
description: description || null,
|
||||||
|
cidrs: cidrs
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide modal
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('createSiteModal')).hide();
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('create-site-form').reset();
|
||||||
|
document.getElementById('cidrs-container').innerHTML = '';
|
||||||
|
cidrInputCounter = 0;
|
||||||
|
|
||||||
|
// Reload sites
|
||||||
|
await loadSites();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showAlert('success', `Site "${name}" created successfully`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating site:', error);
|
||||||
|
showAlert('danger', `Error creating site: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View site details
|
||||||
|
async function viewSite(siteId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sites/${siteId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load site: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const site = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('view-site-name').textContent = site.name;
|
||||||
|
document.getElementById('view-site-description').textContent = site.description || 'No description';
|
||||||
|
|
||||||
|
// Render CIDRs
|
||||||
|
const cidrsHtml = site.cidrs && site.cidrs.length > 0
|
||||||
|
? `<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>CIDR</th>
|
||||||
|
<th>Ping</th>
|
||||||
|
<th>TCP Ports</th>
|
||||||
|
<th>UDP Ports</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${site.cidrs.map(cidr => `
|
||||||
|
<tr>
|
||||||
|
<td><code>${cidr.cidr}</code></td>
|
||||||
|
<td>${cidr.expected_ping ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'}</td>
|
||||||
|
<td>${cidr.expected_tcp_ports?.length > 0 ? cidr.expected_tcp_ports.join(', ') : '-'}</td>
|
||||||
|
<td>${cidr.expected_udp_ports?.length > 0 ? cidr.expected_udp_ports.join(', ') : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`
|
||||||
|
: '<p style="color: #94a3b8;"><em>No CIDRs defined</em></p>';
|
||||||
|
|
||||||
|
document.getElementById('view-site-cidrs').innerHTML = cidrsHtml;
|
||||||
|
|
||||||
|
// Load usage
|
||||||
|
document.getElementById('view-site-usage').innerHTML = '<p style="color: #94a3b8;"><i class="bi bi-hourglass"></i> Loading usage...</p>';
|
||||||
|
|
||||||
|
const usageResponse = await fetch(`/api/sites/${siteId}/usage`);
|
||||||
|
if (usageResponse.ok) {
|
||||||
|
const usage = await usageResponse.json();
|
||||||
|
|
||||||
|
const usageHtml = usage.count > 0
|
||||||
|
? `<p style="color: #e2e8f0;">Used in <strong>${usage.count}</strong> scan(s)</p>
|
||||||
|
<ul style="color: #94a3b8;">${usage.scans.map(scan =>
|
||||||
|
`<li>${scan.title} - ${new Date(scan.timestamp).toLocaleString()} (${scan.status})</li>`
|
||||||
|
).join('')}</ul>`
|
||||||
|
: '<p style="color: #94a3b8;"><em>Not used in any scans</em></p>';
|
||||||
|
|
||||||
|
document.getElementById('view-site-usage').innerHTML = usageHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('viewSiteModal')).show();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error viewing site:', error);
|
||||||
|
showAlert('danger', `Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit site
|
||||||
|
async function editSite(siteId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sites/${siteId}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load site');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Populate form
|
||||||
|
document.getElementById('edit-site-id').value = data.id;
|
||||||
|
document.getElementById('edit-site-name').value = data.name;
|
||||||
|
document.getElementById('edit-site-description').value = data.description || '';
|
||||||
|
|
||||||
|
// Hide error
|
||||||
|
document.getElementById('edit-site-error').style.display = 'none';
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
new bootstrap.Modal(document.getElementById('editSiteModal')).show();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading site:', error);
|
||||||
|
showAlert('danger', `Error loading site: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save edited site
|
||||||
|
document.getElementById('save-edit-site-btn').addEventListener('click', async function() {
|
||||||
|
const siteId = document.getElementById('edit-site-id').value;
|
||||||
|
const name = document.getElementById('edit-site-name').value.trim();
|
||||||
|
const description = document.getElementById('edit-site-description').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
document.getElementById('edit-site-error-message').textContent = 'Site name is required';
|
||||||
|
document.getElementById('edit-site-error').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sites/${siteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, description: description || null })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.message || 'Failed to update site');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('editSiteModal')).hide();
|
||||||
|
|
||||||
|
// Reload sites
|
||||||
|
await loadSites();
|
||||||
|
|
||||||
|
showAlert('success', `Site "${name}" updated successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating site:', error);
|
||||||
|
document.getElementById('edit-site-error-message').textContent = error.message;
|
||||||
|
document.getElementById('edit-site-error').style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm delete
|
||||||
|
async function confirmDelete(siteId, siteName) {
|
||||||
|
selectedSiteForDeletion = siteId;
|
||||||
|
document.getElementById('delete-site-name').textContent = siteName;
|
||||||
|
|
||||||
|
// Check if site is in use
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sites/${siteId}/usage`);
|
||||||
|
if (response.ok) {
|
||||||
|
const usage = await response.json();
|
||||||
|
|
||||||
|
const warningDiv = document.getElementById('delete-warning');
|
||||||
|
const warningMsg = document.getElementById('delete-warning-message');
|
||||||
|
const deleteBtn = document.getElementById('confirm-delete-btn');
|
||||||
|
|
||||||
|
if (usage.count > 0) {
|
||||||
|
warningDiv.style.display = 'block';
|
||||||
|
warningMsg.textContent = `This site is used in ${usage.count} scan(s) and cannot be deleted.`;
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
deleteBtn.classList.add('disabled');
|
||||||
|
} else {
|
||||||
|
warningDiv.style.display = 'none';
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
deleteBtn.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error checking site usage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete site
|
||||||
|
async function deleteSite() {
|
||||||
|
if (!selectedSiteForDeletion) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sites/${selectedSiteForDeletion}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide modal
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
|
||||||
|
|
||||||
|
// Reload sites
|
||||||
|
await loadSites();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showAlert('success', `Site deleted successfully`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting site:', error);
|
||||||
|
showAlert('danger', `Error deleting site: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show alert
|
||||||
|
function showAlert(type, message) {
|
||||||
|
const alertHtml = `
|
||||||
|
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const container = document.querySelector('.container-fluid');
|
||||||
|
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||||||
|
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
const alert = container.querySelector('.alert');
|
||||||
|
if (alert) {
|
||||||
|
bootstrap.Alert.getInstance(alert)?.close();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
|
||||||
|
if (!searchTerm) {
|
||||||
|
renderSites(sitesData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = sitesData.filter(site =>
|
||||||
|
site.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
(site.description && site.description.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
|
||||||
|
renderSites(filtered);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup delete button
|
||||||
|
document.getElementById('confirm-delete-btn').addEventListener('click', deleteSite);
|
||||||
|
|
||||||
|
// Initialize modal
|
||||||
|
document.getElementById('createSiteModal').addEventListener('show.bs.modal', function() {
|
||||||
|
// Reset form and add one CIDR input
|
||||||
|
document.getElementById('create-site-form').reset();
|
||||||
|
document.getElementById('cidrs-container').innerHTML = '';
|
||||||
|
cidrInputCounter = 0;
|
||||||
|
addCidrInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load sites on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', loadSites);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: #334155;
|
||||||
|
border-bottom: 1px solid #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
color: #94a3b8;
|
||||||
|
border-bottom: 1px solid #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr {
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: #334155;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user