Migrate from file-based configs to database with per-IP site configuration
Major architectural changes: - Replace YAML config files with database-stored ScanConfig model - Remove CIDR block support in favor of individual IP addresses per site - Each IP now has its own expected_ping, expected_tcp_ports, expected_udp_ports - AlertRule now uses config_id FK instead of config_file string API changes: - POST /api/scans now requires config_id instead of config_file - Alert rules API uses config_id with validation - All config dropdowns fetch from /api/configs dynamically Template updates: - scans.html, dashboard.html, alert_rules.html load configs via API - Display format: Config Title (X sites) in dropdowns - Removed Jinja2 config_files loops Migrations: - 008: Expand CIDRs to individual IPs with per-IP port configs - 009: Remove CIDR-related columns - 010: Add config_id to alert_rules, remove config_file
This commit is contained in:
270
app/migrations/versions/008_expand_cidrs_to_ips.py
Normal file
270
app/migrations/versions/008_expand_cidrs_to_ips.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Expand CIDRs to individual IPs with per-IP settings
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2025-11-19
|
||||
|
||||
This migration changes the site architecture to automatically expand CIDRs into
|
||||
individual IPs in the database. Each IP has its own port and ping settings.
|
||||
|
||||
Changes:
|
||||
- Add site_id to site_ips (direct link to sites, support standalone IPs)
|
||||
- Make site_cidr_id nullable (IPs can exist without a CIDR parent)
|
||||
- Remove settings from site_cidrs (settings now only at IP level)
|
||||
- Add unique constraint: no duplicate IPs within a site
|
||||
- Expand existing CIDRs to individual IPs
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
import ipaddress
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic
|
||||
revision = '008'
|
||||
down_revision = '007'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Modify schema to support per-IP settings and auto-expand CIDRs.
|
||||
"""
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
# Check if site_id column already exists
|
||||
inspector = sa.inspect(connection)
|
||||
site_ips_columns = [col['name'] for col in inspector.get_columns('site_ips')]
|
||||
site_cidrs_columns = [col['name'] for col in inspector.get_columns('site_cidrs')]
|
||||
|
||||
# Step 1: Add site_id column to site_ips (will be populated from site_cidr_id)
|
||||
if 'site_id' not in site_ips_columns:
|
||||
print("Adding site_id column to site_ips...")
|
||||
op.add_column('site_ips', sa.Column('site_id', sa.Integer(), nullable=True, comment='FK to sites (direct link)'))
|
||||
else:
|
||||
print("site_id column already exists in site_ips, skipping...")
|
||||
|
||||
# Step 2: Populate site_id from site_cidr_id (before we make it nullable)
|
||||
print("Populating site_id from existing site_cidr relationships...")
|
||||
connection.execute(text("""
|
||||
UPDATE site_ips
|
||||
SET site_id = (
|
||||
SELECT site_id
|
||||
FROM site_cidrs
|
||||
WHERE site_cidrs.id = site_ips.site_cidr_id
|
||||
)
|
||||
WHERE site_cidr_id IS NOT NULL
|
||||
"""))
|
||||
|
||||
# Step 3: Make site_id NOT NULL and add foreign key
|
||||
# Check if foreign key exists before creating
|
||||
try:
|
||||
op.alter_column('site_ips', 'site_id', nullable=False)
|
||||
print("Made site_id NOT NULL")
|
||||
except Exception as e:
|
||||
print(f"site_id already NOT NULL or error: {e}")
|
||||
|
||||
# Check if foreign key exists
|
||||
try:
|
||||
op.create_foreign_key('fk_site_ips_site_id', 'site_ips', 'sites', ['site_id'], ['id'])
|
||||
print("Created foreign key fk_site_ips_site_id")
|
||||
except Exception as e:
|
||||
print(f"Foreign key already exists or error: {e}")
|
||||
|
||||
# Check if index exists
|
||||
try:
|
||||
op.create_index(op.f('ix_site_ips_site_id'), 'site_ips', ['site_id'], unique=False)
|
||||
print("Created index ix_site_ips_site_id")
|
||||
except Exception as e:
|
||||
print(f"Index already exists or error: {e}")
|
||||
|
||||
# Step 4: Make site_cidr_id nullable (for standalone IPs)
|
||||
try:
|
||||
op.alter_column('site_ips', 'site_cidr_id', nullable=True)
|
||||
print("Made site_cidr_id nullable")
|
||||
except Exception as e:
|
||||
print(f"site_cidr_id already nullable or error: {e}")
|
||||
|
||||
# Step 5: Drop old unique constraint and create new one (site_id, ip_address)
|
||||
# This prevents duplicate IPs within a site (across all CIDRs and standalone)
|
||||
try:
|
||||
op.drop_constraint('uix_site_cidr_ip', 'site_ips', type_='unique')
|
||||
print("Dropped old constraint uix_site_cidr_ip")
|
||||
except Exception as e:
|
||||
print(f"Constraint already dropped or doesn't exist: {e}")
|
||||
|
||||
try:
|
||||
op.create_unique_constraint('uix_site_ip_address', 'site_ips', ['site_id', 'ip_address'])
|
||||
print("Created new constraint uix_site_ip_address")
|
||||
except Exception as e:
|
||||
print(f"Constraint already exists or error: {e}")
|
||||
|
||||
# Step 6: Expand existing CIDRs to individual IPs
|
||||
print("Expanding existing CIDRs to individual IPs...")
|
||||
|
||||
# Get all existing CIDRs
|
||||
cidrs = connection.execute(text("""
|
||||
SELECT id, site_id, cidr, expected_ping, expected_tcp_ports, expected_udp_ports
|
||||
FROM site_cidrs
|
||||
""")).fetchall()
|
||||
|
||||
expanded_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for cidr_row in cidrs:
|
||||
cidr_id, site_id, cidr_str, expected_ping, expected_tcp_ports, expected_udp_ports = cidr_row
|
||||
|
||||
try:
|
||||
# Parse CIDR
|
||||
network = ipaddress.ip_network(cidr_str, strict=False)
|
||||
|
||||
# Check size - skip if too large (> /24 for IPv4, > /64 for IPv6)
|
||||
if isinstance(network, ipaddress.IPv4Network) and network.prefixlen < 24:
|
||||
print(f" ⚠ Skipping large CIDR {cidr_str} (>{network.num_addresses} IPs)")
|
||||
skipped_count += 1
|
||||
continue
|
||||
elif isinstance(network, ipaddress.IPv6Network) and network.prefixlen < 64:
|
||||
print(f" ⚠ Skipping large CIDR {cidr_str} (>{network.num_addresses} IPs)")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Expand to individual IPs
|
||||
for ip in network.hosts() if network.num_addresses > 2 else [network.network_address]:
|
||||
ip_str = str(ip)
|
||||
|
||||
# Check if this IP already exists (from previous IP overrides)
|
||||
existing = connection.execute(text("""
|
||||
SELECT id FROM site_ips
|
||||
WHERE site_cidr_id = :cidr_id AND ip_address = :ip_address
|
||||
"""), {'cidr_id': cidr_id, 'ip_address': ip_str}).fetchone()
|
||||
|
||||
if not existing:
|
||||
# Insert new IP with settings from CIDR
|
||||
connection.execute(text("""
|
||||
INSERT INTO site_ips (
|
||||
site_id, site_cidr_id, ip_address,
|
||||
expected_ping, expected_tcp_ports, expected_udp_ports,
|
||||
created_at
|
||||
)
|
||||
VALUES (
|
||||
:site_id, :cidr_id, :ip_address,
|
||||
:expected_ping, :expected_tcp_ports, :expected_udp_ports,
|
||||
datetime('now')
|
||||
)
|
||||
"""), {
|
||||
'site_id': site_id,
|
||||
'cidr_id': cidr_id,
|
||||
'ip_address': ip_str,
|
||||
'expected_ping': expected_ping,
|
||||
'expected_tcp_ports': expected_tcp_ports,
|
||||
'expected_udp_ports': expected_udp_ports
|
||||
})
|
||||
expanded_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Error expanding CIDR {cidr_str}: {e}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
print(f" ✓ Expanded {expanded_count} IPs from CIDRs")
|
||||
if skipped_count > 0:
|
||||
print(f" ⚠ Skipped {skipped_count} CIDRs (too large or errors)")
|
||||
|
||||
# Step 7: Remove settings columns from site_cidrs (now only at IP level)
|
||||
print("Removing settings columns from site_cidrs...")
|
||||
# Re-inspect to get current columns
|
||||
site_cidrs_columns = [col['name'] for col in inspector.get_columns('site_cidrs')]
|
||||
|
||||
if 'expected_ping' in site_cidrs_columns:
|
||||
try:
|
||||
op.drop_column('site_cidrs', 'expected_ping')
|
||||
print("Dropped expected_ping from site_cidrs")
|
||||
except Exception as e:
|
||||
print(f"Error dropping expected_ping: {e}")
|
||||
else:
|
||||
print("expected_ping already dropped from site_cidrs")
|
||||
|
||||
if 'expected_tcp_ports' in site_cidrs_columns:
|
||||
try:
|
||||
op.drop_column('site_cidrs', 'expected_tcp_ports')
|
||||
print("Dropped expected_tcp_ports from site_cidrs")
|
||||
except Exception as e:
|
||||
print(f"Error dropping expected_tcp_ports: {e}")
|
||||
else:
|
||||
print("expected_tcp_ports already dropped from site_cidrs")
|
||||
|
||||
if 'expected_udp_ports' in site_cidrs_columns:
|
||||
try:
|
||||
op.drop_column('site_cidrs', 'expected_udp_ports')
|
||||
print("Dropped expected_udp_ports from site_cidrs")
|
||||
except Exception as e:
|
||||
print(f"Error dropping expected_udp_ports: {e}")
|
||||
else:
|
||||
print("expected_udp_ports already dropped from site_cidrs")
|
||||
|
||||
# Print summary
|
||||
total_sites = connection.execute(text('SELECT COUNT(*) FROM sites')).scalar()
|
||||
total_cidrs = connection.execute(text('SELECT COUNT(*) FROM site_cidrs')).scalar()
|
||||
total_ips = connection.execute(text('SELECT COUNT(*) FROM site_ips')).scalar()
|
||||
|
||||
print("\n✓ Migration 008 complete: CIDRs expanded to individual IPs")
|
||||
print(f" - Total sites: {total_sites}")
|
||||
print(f" - Total CIDRs: {total_cidrs}")
|
||||
print(f" - Total IPs: {total_ips}")
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""
|
||||
Revert schema changes (restore CIDR-level settings).
|
||||
Note: This will lose per-IP granularity!
|
||||
"""
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
print("Rolling back to CIDR-level settings...")
|
||||
|
||||
# Step 1: Add settings columns back to site_cidrs
|
||||
op.add_column('site_cidrs', sa.Column('expected_ping', sa.Boolean(), nullable=True))
|
||||
op.add_column('site_cidrs', sa.Column('expected_tcp_ports', sa.Text(), nullable=True))
|
||||
op.add_column('site_cidrs', sa.Column('expected_udp_ports', sa.Text(), nullable=True))
|
||||
|
||||
# Step 2: Populate CIDR settings from first IP in each CIDR (approximation)
|
||||
connection.execute(text("""
|
||||
UPDATE site_cidrs
|
||||
SET
|
||||
expected_ping = (
|
||||
SELECT expected_ping FROM site_ips
|
||||
WHERE site_ips.site_cidr_id = site_cidrs.id
|
||||
LIMIT 1
|
||||
),
|
||||
expected_tcp_ports = (
|
||||
SELECT expected_tcp_ports FROM site_ips
|
||||
WHERE site_ips.site_cidr_id = site_cidrs.id
|
||||
LIMIT 1
|
||||
),
|
||||
expected_udp_ports = (
|
||||
SELECT expected_udp_ports FROM site_ips
|
||||
WHERE site_ips.site_cidr_id = site_cidrs.id
|
||||
LIMIT 1
|
||||
)
|
||||
"""))
|
||||
|
||||
# Step 3: Delete auto-expanded IPs (keep only original overrides)
|
||||
# In practice, this is difficult to determine, so we'll keep all IPs
|
||||
# and just remove the schema changes
|
||||
|
||||
# Step 4: Drop new unique constraint and restore old one
|
||||
op.drop_constraint('uix_site_ip_address', 'site_ips', type_='unique')
|
||||
op.create_unique_constraint('uix_site_cidr_ip', 'site_ips', ['site_cidr_id', 'ip_address'])
|
||||
|
||||
# Step 5: Make site_cidr_id NOT NULL again
|
||||
op.alter_column('site_ips', 'site_cidr_id', nullable=False)
|
||||
|
||||
# Step 6: Drop site_id column and related constraints
|
||||
op.drop_index(op.f('ix_site_ips_site_id'), table_name='site_ips')
|
||||
op.drop_constraint('fk_site_ips_site_id', 'site_ips', type_='foreignkey')
|
||||
op.drop_column('site_ips', 'site_id')
|
||||
|
||||
print("✓ Downgrade complete: Reverted to CIDR-level settings")
|
||||
210
app/migrations/versions/009_remove_cidrs.py
Normal file
210
app/migrations/versions/009_remove_cidrs.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Remove CIDR table - make sites IP-only
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2025-11-19
|
||||
|
||||
This migration removes the SiteCIDR table entirely, making sites purely
|
||||
IP-based. CIDRs are now only used as a convenience for bulk IP addition,
|
||||
not stored as permanent entities.
|
||||
|
||||
Changes:
|
||||
- Set all site_ips.site_cidr_id to NULL (preserve all IPs)
|
||||
- Drop foreign key from site_ips to site_cidrs
|
||||
- Drop site_cidrs table
|
||||
- Remove site_cidr_id column from site_ips
|
||||
|
||||
All existing IPs are preserved. They become "standalone" IPs without
|
||||
a CIDR parent.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic
|
||||
revision = '009'
|
||||
down_revision = '008'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Remove CIDR table and make all IPs standalone.
|
||||
"""
|
||||
|
||||
connection = op.get_bind()
|
||||
inspector = sa.inspect(connection)
|
||||
|
||||
print("\n=== Migration 009: Remove CIDR Table ===\n")
|
||||
|
||||
# Get counts before migration
|
||||
try:
|
||||
total_cidrs = connection.execute(text('SELECT COUNT(*) FROM site_cidrs')).scalar()
|
||||
total_ips = connection.execute(text('SELECT COUNT(*) FROM site_ips')).scalar()
|
||||
ips_with_cidr = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM site_ips WHERE site_cidr_id IS NOT NULL'
|
||||
)).scalar()
|
||||
|
||||
print(f"Before migration:")
|
||||
print(f" - Total CIDRs: {total_cidrs}")
|
||||
print(f" - Total IPs: {total_ips}")
|
||||
print(f" - IPs linked to CIDRs: {ips_with_cidr}")
|
||||
print(f" - Standalone IPs: {total_ips - ips_with_cidr}\n")
|
||||
except Exception as e:
|
||||
print(f"Could not get pre-migration stats: {e}\n")
|
||||
|
||||
# Step 1: Set all site_cidr_id to NULL (preserve all IPs as standalone)
|
||||
print("Step 1: Converting all IPs to standalone (nulling CIDR associations)...")
|
||||
try:
|
||||
result = connection.execute(text("""
|
||||
UPDATE site_ips
|
||||
SET site_cidr_id = NULL
|
||||
WHERE site_cidr_id IS NOT NULL
|
||||
"""))
|
||||
print(f" ✓ Converted {result.rowcount} IPs to standalone\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Error or already done: {e}\n")
|
||||
|
||||
# Step 2: Drop foreign key constraint from site_ips to site_cidrs
|
||||
print("Step 2: Dropping foreign key constraint from site_ips to site_cidrs...")
|
||||
foreign_keys = inspector.get_foreign_keys('site_ips')
|
||||
fk_to_drop = None
|
||||
|
||||
for fk in foreign_keys:
|
||||
if fk['referred_table'] == 'site_cidrs':
|
||||
fk_to_drop = fk['name']
|
||||
break
|
||||
|
||||
if fk_to_drop:
|
||||
try:
|
||||
op.drop_constraint(fk_to_drop, 'site_ips', type_='foreignkey')
|
||||
print(f" ✓ Dropped foreign key constraint: {fk_to_drop}\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not drop foreign key: {e}\n")
|
||||
else:
|
||||
print(" ⚠ Foreign key constraint not found or already dropped\n")
|
||||
|
||||
# Step 3: Drop index on site_cidr_id (if exists)
|
||||
print("Step 3: Dropping index on site_cidr_id...")
|
||||
indexes = inspector.get_indexes('site_ips')
|
||||
index_to_drop = None
|
||||
|
||||
for idx in indexes:
|
||||
if 'site_cidr_id' in idx['column_names']:
|
||||
index_to_drop = idx['name']
|
||||
break
|
||||
|
||||
if index_to_drop:
|
||||
try:
|
||||
op.drop_index(index_to_drop, table_name='site_ips')
|
||||
print(f" ✓ Dropped index: {index_to_drop}\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not drop index: {e}\n")
|
||||
else:
|
||||
print(" ⚠ Index not found or already dropped\n")
|
||||
|
||||
# Step 4: Drop site_cidrs table
|
||||
print("Step 4: Dropping site_cidrs table...")
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'site_cidrs' in tables:
|
||||
try:
|
||||
op.drop_table('site_cidrs')
|
||||
print(" ✓ Dropped site_cidrs table\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not drop table: {e}\n")
|
||||
else:
|
||||
print(" ⚠ Table site_cidrs not found or already dropped\n")
|
||||
|
||||
# Step 5: Drop site_cidr_id column from site_ips
|
||||
print("Step 5: Dropping site_cidr_id column from site_ips...")
|
||||
site_ips_columns = [col['name'] for col in inspector.get_columns('site_ips')]
|
||||
|
||||
if 'site_cidr_id' in site_ips_columns:
|
||||
try:
|
||||
op.drop_column('site_ips', 'site_cidr_id')
|
||||
print(" ✓ Dropped site_cidr_id column from site_ips\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not drop column: {e}\n")
|
||||
else:
|
||||
print(" ⚠ Column site_cidr_id not found or already dropped\n")
|
||||
|
||||
# Get counts after migration
|
||||
try:
|
||||
final_ips = connection.execute(text('SELECT COUNT(*) FROM site_ips')).scalar()
|
||||
total_sites = connection.execute(text('SELECT COUNT(*) FROM sites')).scalar()
|
||||
|
||||
print("After migration:")
|
||||
print(f" - Total sites: {total_sites}")
|
||||
print(f" - Total IPs (all standalone): {final_ips}")
|
||||
print(f" - CIDRs: N/A (table removed)")
|
||||
except Exception as e:
|
||||
print(f"Could not get post-migration stats: {e}")
|
||||
|
||||
print("\n✓ Migration 009 complete: Sites are now IP-only")
|
||||
print(" All IPs preserved as standalone. CIDRs can still be used")
|
||||
print(" via the API/UI for bulk IP creation, but are not stored.\n")
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""
|
||||
Recreate site_cidrs table (CANNOT restore original CIDR associations).
|
||||
|
||||
WARNING: This downgrade creates an empty site_cidrs table structure but
|
||||
cannot restore the original CIDR-to-IP associations since that data was
|
||||
deleted. All IPs will remain standalone.
|
||||
"""
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
print("\n=== Downgrade 009: Recreate CIDR Table Structure ===\n")
|
||||
print("⚠ WARNING: Cannot restore original CIDR associations!")
|
||||
print(" The site_cidrs table structure will be recreated but will be empty.")
|
||||
print(" All IPs will remain standalone. This is a PARTIAL downgrade.\n")
|
||||
|
||||
# Step 1: Recreate site_cidrs table (empty)
|
||||
print("Step 1: Recreating site_cidrs table structure...")
|
||||
try:
|
||||
op.create_table(
|
||||
'site_cidrs',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('site_id', sa.Integer(), nullable=False),
|
||||
sa.Column('cidr', sa.String(length=45), nullable=False, comment='CIDR notation (e.g., 10.0.0.0/24)'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ),
|
||||
sa.UniqueConstraint('site_id', 'cidr', name='uix_site_cidr')
|
||||
)
|
||||
print(" ✓ Recreated site_cidrs table (empty)\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not create table: {e}\n")
|
||||
|
||||
# Step 2: Add site_cidr_id column back to site_ips (nullable)
|
||||
print("Step 2: Adding site_cidr_id column back to site_ips...")
|
||||
try:
|
||||
op.add_column('site_ips', sa.Column('site_cidr_id', sa.Integer(), nullable=True, comment='FK to site_cidrs (optional, for grouping)'))
|
||||
print(" ✓ Added site_cidr_id column (nullable)\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not add column: {e}\n")
|
||||
|
||||
# Step 3: Add foreign key constraint
|
||||
print("Step 3: Adding foreign key constraint...")
|
||||
try:
|
||||
op.create_foreign_key('fk_site_ips_site_cidr_id', 'site_ips', 'site_cidrs', ['site_cidr_id'], ['id'])
|
||||
print(" ✓ Created foreign key constraint\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not create foreign key: {e}\n")
|
||||
|
||||
# Step 4: Add index on site_cidr_id
|
||||
print("Step 4: Adding index on site_cidr_id...")
|
||||
try:
|
||||
op.create_index('ix_site_ips_site_cidr_id', 'site_ips', ['site_cidr_id'], unique=False)
|
||||
print(" ✓ Created index on site_cidr_id\n")
|
||||
except Exception as e:
|
||||
print(f" ⚠ Could not create index: {e}\n")
|
||||
|
||||
print("✓ Downgrade complete: CIDR table structure restored (but empty)")
|
||||
print(" All IPs remain standalone. You would need to manually recreate")
|
||||
print(" CIDR records and associate IPs with them.\n")
|
||||
53
app/migrations/versions/010_alert_rules_config_id.py
Normal file
53
app/migrations/versions/010_alert_rules_config_id.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Add config_id to alert_rules table
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2025-11-19
|
||||
|
||||
This migration adds config_id foreign key to alert_rules table to replace
|
||||
the config_file column, completing the migration from file-based to
|
||||
database-based configurations.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic
|
||||
revision = '010'
|
||||
down_revision = '009'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Add config_id to alert_rules table and remove config_file.
|
||||
"""
|
||||
|
||||
with op.batch_alter_table('alert_rules', schema=None) as batch_op:
|
||||
# Add config_id column with foreign key
|
||||
batch_op.add_column(sa.Column('config_id', sa.Integer(), nullable=True, comment='FK to scan_configs table'))
|
||||
batch_op.create_index('ix_alert_rules_config_id', ['config_id'], unique=False)
|
||||
batch_op.create_foreign_key('fk_alert_rules_config_id', 'scan_configs', ['config_id'], ['id'])
|
||||
|
||||
# Remove the old config_file column
|
||||
batch_op.drop_column('config_file')
|
||||
|
||||
print("✓ Migration complete: AlertRule now uses config_id")
|
||||
print(" - Added config_id foreign key to alert_rules table")
|
||||
print(" - Removed deprecated config_file column")
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove config_id and restore config_file on alert_rules."""
|
||||
|
||||
with op.batch_alter_table('alert_rules', schema=None) as batch_op:
|
||||
# Remove foreign key and config_id column
|
||||
batch_op.drop_constraint('fk_alert_rules_config_id', type_='foreignkey')
|
||||
batch_op.drop_index('ix_alert_rules_config_id')
|
||||
batch_op.drop_column('config_id')
|
||||
|
||||
# Restore config_file column
|
||||
batch_op.add_column(sa.Column('config_file', sa.String(255), nullable=True, comment='Optional: specific config file this rule applies to'))
|
||||
|
||||
print("✓ Downgrade complete: AlertRule config_id removed, config_file restored")
|
||||
@@ -261,14 +261,14 @@ class SneakyScanner:
|
||||
"""
|
||||
Load a site definition from the database.
|
||||
|
||||
IPs are pre-expanded in the database, so we just load them directly.
|
||||
|
||||
Args:
|
||||
site_name: Name of the site to load
|
||||
|
||||
Returns:
|
||||
Site definition dict with expanded IPs, or None if not found
|
||||
Site definition dict with IPs, or None if not found
|
||||
"""
|
||||
import ipaddress
|
||||
|
||||
try:
|
||||
# Import database modules
|
||||
import os
|
||||
@@ -281,7 +281,7 @@ class SneakyScanner:
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, joinedload
|
||||
from web.models import Site, SiteCIDR
|
||||
from web.models import Site
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db')
|
||||
@@ -291,12 +291,10 @@ class SneakyScanner:
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
# Query site with CIDRs and IP overrides
|
||||
# Query site with all IPs (CIDRs are already expanded)
|
||||
site = (
|
||||
session.query(Site)
|
||||
.options(
|
||||
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
|
||||
)
|
||||
.options(joinedload(Site.ips))
|
||||
.filter(Site.name == site_name)
|
||||
.first()
|
||||
)
|
||||
@@ -305,60 +303,25 @@ class SneakyScanner:
|
||||
session.close()
|
||||
return None
|
||||
|
||||
# Expand CIDRs to IP list
|
||||
# Load all IPs directly from database (already expanded)
|
||||
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 []
|
||||
for ip_obj in site.ips:
|
||||
# Get settings from IP (no need to merge with CIDR defaults)
|
||||
expected_ping = ip_obj.expected_ping if ip_obj.expected_ping is not None else False
|
||||
expected_tcp_ports = json.loads(ip_obj.expected_tcp_ports) if ip_obj.expected_tcp_ports else []
|
||||
expected_udp_ports = json.loads(ip_obj.expected_udp_ports) if ip_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
|
||||
ip_config = {
|
||||
'address': ip_obj.ip_address,
|
||||
'expected': {
|
||||
'ping': expected_ping,
|
||||
'tcp_ports': expected_tcp_ports,
|
||||
'udp_ports': 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
|
||||
expanded_ips.append(ip_config)
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -169,7 +169,8 @@ def list_alert_rules():
|
||||
'webhook_enabled': rule.webhook_enabled,
|
||||
'severity': rule.severity,
|
||||
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
|
||||
'config_file': rule.config_file,
|
||||
'config_id': rule.config_id,
|
||||
'config_title': rule.config.title if rule.config else None,
|
||||
'created_at': rule.created_at.isoformat(),
|
||||
'updated_at': rule.updated_at.isoformat() if rule.updated_at else None
|
||||
})
|
||||
@@ -195,7 +196,7 @@ def create_alert_rule():
|
||||
webhook_enabled: Send webhook for this rule (default: false)
|
||||
severity: Alert severity (critical, warning, info)
|
||||
filter_conditions: JSON object with filter conditions
|
||||
config_file: Optional config file to apply rule to
|
||||
config_id: Optional config ID to apply rule to
|
||||
|
||||
Returns:
|
||||
JSON response with created rule
|
||||
@@ -226,6 +227,17 @@ def create_alert_rule():
|
||||
}), 400
|
||||
|
||||
try:
|
||||
# Validate config_id if provided
|
||||
config_id = data.get('config_id')
|
||||
if config_id:
|
||||
from web.models import ScanConfig
|
||||
config = current_app.db_session.query(ScanConfig).filter_by(id=config_id).first()
|
||||
if not config:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Config with ID {config_id} not found'
|
||||
}), 400
|
||||
|
||||
# Create new rule
|
||||
rule = AlertRule(
|
||||
name=data.get('name', f"{data['rule_type']} rule"),
|
||||
@@ -236,7 +248,7 @@ def create_alert_rule():
|
||||
webhook_enabled=data.get('webhook_enabled', False),
|
||||
severity=data.get('severity', 'warning'),
|
||||
filter_conditions=json.dumps(data['filter_conditions']) if data.get('filter_conditions') else None,
|
||||
config_file=data.get('config_file'),
|
||||
config_id=config_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -257,7 +269,8 @@ def create_alert_rule():
|
||||
'webhook_enabled': rule.webhook_enabled,
|
||||
'severity': rule.severity,
|
||||
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
|
||||
'config_file': rule.config_file,
|
||||
'config_id': rule.config_id,
|
||||
'config_title': rule.config.title if rule.config else None,
|
||||
'created_at': rule.created_at.isoformat(),
|
||||
'updated_at': rule.updated_at.isoformat()
|
||||
}
|
||||
@@ -288,7 +301,7 @@ def update_alert_rule(rule_id):
|
||||
webhook_enabled: Send webhook for this rule (optional)
|
||||
severity: Alert severity (optional)
|
||||
filter_conditions: JSON object with filter conditions (optional)
|
||||
config_file: Config file to apply rule to (optional)
|
||||
config_id: Config ID to apply rule to (optional)
|
||||
|
||||
Returns:
|
||||
JSON response with update status
|
||||
@@ -312,6 +325,18 @@ def update_alert_rule(rule_id):
|
||||
}), 400
|
||||
|
||||
try:
|
||||
# Validate config_id if provided
|
||||
if 'config_id' in data:
|
||||
config_id = data['config_id']
|
||||
if config_id:
|
||||
from web.models import ScanConfig
|
||||
config = current_app.db_session.query(ScanConfig).filter_by(id=config_id).first()
|
||||
if not config:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Config with ID {config_id} not found'
|
||||
}), 400
|
||||
|
||||
# Update fields if provided
|
||||
if 'name' in data:
|
||||
rule.name = data['name']
|
||||
@@ -327,8 +352,8 @@ def update_alert_rule(rule_id):
|
||||
rule.severity = data['severity']
|
||||
if 'filter_conditions' in data:
|
||||
rule.filter_conditions = json.dumps(data['filter_conditions']) if data['filter_conditions'] else None
|
||||
if 'config_file' in data:
|
||||
rule.config_file = data['config_file']
|
||||
if 'config_id' in data:
|
||||
rule.config_id = data['config_id']
|
||||
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -347,7 +372,8 @@ def update_alert_rule(rule_id):
|
||||
'webhook_enabled': rule.webhook_enabled,
|
||||
'severity': rule.severity,
|
||||
'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None,
|
||||
'config_file': rule.config_file,
|
||||
'config_id': rule.config_id,
|
||||
'config_title': rule.config.title if rule.config else None,
|
||||
'created_at': rule.created_at.isoformat(),
|
||||
'updated_at': rule.updated_at.isoformat()
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ def get_config(config_id: int):
|
||||
"id": 1,
|
||||
"name": "Production DC",
|
||||
"description": "...",
|
||||
"cidr_count": 5
|
||||
"ip_count": 5
|
||||
}
|
||||
],
|
||||
"created_at": "2025-11-19T10:30:00Z",
|
||||
|
||||
@@ -129,7 +129,7 @@ def trigger_scan():
|
||||
Trigger a new scan.
|
||||
|
||||
Request body:
|
||||
config_file: Path to YAML config file
|
||||
config_id: Database config ID (required)
|
||||
|
||||
Returns:
|
||||
JSON response with scan_id and status
|
||||
@@ -137,25 +137,35 @@ def trigger_scan():
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json() or {}
|
||||
config_file = data.get('config_file')
|
||||
config_id = data.get('config_id')
|
||||
|
||||
# Validate required fields
|
||||
if not config_file:
|
||||
logger.warning("Scan trigger request missing config_file")
|
||||
if not config_id:
|
||||
logger.warning("Scan trigger request missing config_id")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'config_file is required'
|
||||
'message': 'config_id is required'
|
||||
}), 400
|
||||
|
||||
# Validate config_id is an integer
|
||||
try:
|
||||
config_id = int(config_id)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"Invalid config_id type: {config_id}")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'config_id must be an integer'
|
||||
}), 400
|
||||
|
||||
# Trigger scan via service
|
||||
scan_service = ScanService(current_app.db_session)
|
||||
scan_id = scan_service.trigger_scan(
|
||||
config_file=config_file,
|
||||
config_id=config_id,
|
||||
triggered_by='api',
|
||||
scheduler=current_app.scheduler
|
||||
)
|
||||
|
||||
logger.info(f"Scan {scan_id} triggered via API: config={config_file}")
|
||||
logger.info(f"Scan {scan_id} triggered via API: config_id={config_id}")
|
||||
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
@@ -164,10 +174,10 @@ def trigger_scan():
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
# Config file validation error
|
||||
# Config validation error
|
||||
error_message = str(e)
|
||||
logger.warning(f"Invalid config file: {error_message}")
|
||||
logger.warning(f"Request data: config_file='{config_file}'")
|
||||
logger.warning(f"Invalid config: {error_message}")
|
||||
logger.warning(f"Request data: config_id='{config_id}'")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': error_message
|
||||
|
||||
@@ -158,21 +158,12 @@ def create_site():
|
||||
}), 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
|
||||
# Create site (empty initially)
|
||||
site_service = SiteService(current_app.db_session)
|
||||
site = site_service.create_site(
|
||||
name=name,
|
||||
description=description,
|
||||
cidrs=cidrs if cidrs else None
|
||||
description=description
|
||||
)
|
||||
|
||||
logger.info(f"Created site '{name}' (id={site['id']})")
|
||||
@@ -294,135 +285,178 @@ def delete_site(site_id):
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:site_id>/cidrs', methods=['POST'])
|
||||
@bp.route('/<int:site_id>/ips/bulk', methods=['POST'])
|
||||
@api_auth_required
|
||||
def add_cidr(site_id):
|
||||
def bulk_add_ips(site_id):
|
||||
"""
|
||||
Add a CIDR range to a site.
|
||||
Bulk add IPs to a site from CIDR or list.
|
||||
|
||||
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)
|
||||
source_type: "cidr" or "list" (required)
|
||||
cidr: CIDR notation if source_type="cidr" (e.g., "10.0.0.0/24")
|
||||
ips: List of IP addresses if source_type="list" (e.g., ["10.0.0.1", "10.0.0.2"])
|
||||
expected_ping: Expected ping response for all IPs (optional)
|
||||
expected_tcp_ports: List of expected TCP ports for all IPs (optional)
|
||||
expected_udp_ports: List of expected UDP ports for all IPs (optional)
|
||||
|
||||
Returns:
|
||||
JSON response with created CIDR data
|
||||
JSON response with count of IPs added and any errors
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate required fields
|
||||
cidr = data.get('cidr')
|
||||
if not cidr:
|
||||
logger.warning("CIDR creation request missing cidr")
|
||||
source_type = data.get('source_type')
|
||||
if source_type not in ['cidr', 'list']:
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'cidr is required'
|
||||
'message': 'source_type must be "cidr" or "list"'
|
||||
}), 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
|
||||
if source_type == 'cidr':
|
||||
cidr = data.get('cidr')
|
||||
if not cidr:
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'cidr is required when source_type="cidr"'
|
||||
}), 400
|
||||
|
||||
result = site_service.bulk_add_ips_from_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"Bulk added {result['ip_count']} IPs from CIDR '{cidr}' to site {site_id}")
|
||||
return jsonify(result), 201
|
||||
|
||||
else: # source_type == 'list'
|
||||
ip_list = data.get('ips', [])
|
||||
if not isinstance(ip_list, list):
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'ips must be a list when source_type="list"'
|
||||
}), 400
|
||||
|
||||
result = site_service.bulk_add_ips_from_list(
|
||||
site_id=site_id,
|
||||
ip_list=ip_list,
|
||||
expected_ping=expected_ping,
|
||||
expected_tcp_ports=expected_tcp_ports,
|
||||
expected_udp_ports=expected_udp_ports
|
||||
)
|
||||
|
||||
logger.info(f"Bulk added {result['ip_count']} IPs from list to site {site_id}")
|
||||
return jsonify(result), 201
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid CIDR creation request: {str(e)}")
|
||||
logger.warning(f"Invalid bulk IP 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)}")
|
||||
logger.error(f"Database error bulk adding IPs to site {site_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to add CIDR'
|
||||
'message': 'Failed to add IPs'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error adding CIDR to site {site_id}: {str(e)}", exc_info=True)
|
||||
logger.error(f"Unexpected error bulk adding IPs 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'])
|
||||
@bp.route('/<int:site_id>/ips', methods=['GET'])
|
||||
@api_auth_required
|
||||
def remove_cidr(site_id, cidr_id):
|
||||
def list_ips(site_id):
|
||||
"""
|
||||
Remove a CIDR range from a site.
|
||||
List IPs in a site with pagination.
|
||||
|
||||
Prevents removal if it's the last CIDR.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
cidr_id: CIDR ID
|
||||
Query params:
|
||||
page: Page number (default: 1)
|
||||
per_page: Items per page (default: 50, max: 200)
|
||||
|
||||
Returns:
|
||||
JSON response with success message
|
||||
JSON response with IPs list and pagination info
|
||||
"""
|
||||
try:
|
||||
site_service = SiteService(current_app.db_session)
|
||||
site_service.remove_cidr(site_id, cidr_id)
|
||||
# Get and validate query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 50, type=int)
|
||||
|
||||
# Validate pagination params
|
||||
page, per_page = validate_page_params(page, per_page, max_per_page=200)
|
||||
|
||||
# Get IPs from service
|
||||
site_service = SiteService(current_app.db_session)
|
||||
paginated_result = site_service.list_ips(
|
||||
site_id=site_id,
|
||||
page=page,
|
||||
per_page=per_page
|
||||
)
|
||||
|
||||
logger.info(f"Listed IPs for site {site_id}: page={page}, per_page={per_page}, total={paginated_result.total}")
|
||||
|
||||
logger.info(f"Removed CIDR {cidr_id} from site {site_id}")
|
||||
return jsonify({
|
||||
'message': f'CIDR {cidr_id} removed successfully'
|
||||
'ips': 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"Cannot remove CIDR {cidr_id} from site {site_id}: {str(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 removing CIDR {cidr_id} from site {site_id}: {str(e)}")
|
||||
logger.error(f"Database error listing IPs for site {site_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to remove CIDR'
|
||||
'message': 'Failed to retrieve IPs'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error removing CIDR {cidr_id} from site {site_id}: {str(e)}", exc_info=True)
|
||||
logger.error(f"Unexpected error listing IPs for 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'])
|
||||
@bp.route('/<int:site_id>/ips', methods=['POST'])
|
||||
@api_auth_required
|
||||
def add_ip_override(site_id, cidr_id):
|
||||
def add_standalone_ip(site_id):
|
||||
"""
|
||||
Add an IP-level expectation override within a CIDR.
|
||||
Add a standalone IP (without CIDR parent) to a site.
|
||||
|
||||
Args:
|
||||
site_id: Site ID (for validation)
|
||||
cidr_id: CIDR ID
|
||||
site_id: Site 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)
|
||||
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 IP override data
|
||||
JSON response with created IP data
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
@@ -430,7 +464,7 @@ def add_ip_override(site_id, cidr_id):
|
||||
# Validate required fields
|
||||
ip_address = data.get('ip_address')
|
||||
if not ip_address:
|
||||
logger.warning("IP override creation request missing ip_address")
|
||||
logger.warning("Standalone IP creation request missing ip_address")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': 'ip_address is required'
|
||||
@@ -440,76 +474,133 @@ def add_ip_override(site_id, cidr_id):
|
||||
expected_tcp_ports = data.get('expected_tcp_ports', [])
|
||||
expected_udp_ports = data.get('expected_udp_ports', [])
|
||||
|
||||
# Add IP override
|
||||
# Add standalone IP
|
||||
site_service = SiteService(current_app.db_session)
|
||||
ip_data = site_service.add_ip_override(
|
||||
cidr_id=cidr_id,
|
||||
ip_data = site_service.add_standalone_ip(
|
||||
site_id=site_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}")
|
||||
logger.info(f"Added standalone IP '{ip_address}' to site {site_id}")
|
||||
return jsonify(ip_data), 201
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid IP override creation request: {str(e)}")
|
||||
logger.warning(f"Invalid standalone IP 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)}")
|
||||
logger.error(f"Database error adding standalone IP to site {site_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to add IP override'
|
||||
'message': 'Failed to add IP'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error adding IP override to CIDR {cidr_id}: {str(e)}", exc_info=True)
|
||||
logger.error(f"Unexpected error adding standalone IP 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>/ips/<int:ip_id>', methods=['DELETE'])
|
||||
@bp.route('/<int:site_id>/ips/<int:ip_id>', methods=['PUT'])
|
||||
@api_auth_required
|
||||
def remove_ip_override(site_id, cidr_id, ip_id):
|
||||
def update_ip_settings(site_id, ip_id):
|
||||
"""
|
||||
Remove an IP-level override.
|
||||
Update settings for an individual IP.
|
||||
|
||||
Args:
|
||||
site_id: Site ID (for validation)
|
||||
cidr_id: CIDR ID
|
||||
ip_id: IP override ID
|
||||
site_id: Site ID
|
||||
ip_id: IP ID
|
||||
|
||||
Request body:
|
||||
expected_ping: New ping expectation (optional)
|
||||
expected_tcp_ports: New TCP ports expectation (optional)
|
||||
expected_udp_ports: New UDP ports expectation (optional)
|
||||
|
||||
Returns:
|
||||
JSON response with updated IP data
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
expected_ping = data.get('expected_ping')
|
||||
expected_tcp_ports = data.get('expected_tcp_ports')
|
||||
expected_udp_ports = data.get('expected_udp_ports')
|
||||
|
||||
# Update IP settings
|
||||
site_service = SiteService(current_app.db_session)
|
||||
ip_data = site_service.update_ip_settings(
|
||||
site_id=site_id,
|
||||
ip_id=ip_id,
|
||||
expected_ping=expected_ping,
|
||||
expected_tcp_ports=expected_tcp_ports,
|
||||
expected_udp_ports=expected_udp_ports
|
||||
)
|
||||
|
||||
logger.info(f"Updated IP {ip_id} in site {site_id}")
|
||||
return jsonify(ip_data)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid IP update request: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Invalid request',
|
||||
'message': str(e)
|
||||
}), 400
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error updating IP {ip_id} in site {site_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to update IP'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error updating IP {ip_id} in 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>/ips/<int:ip_id>', methods=['DELETE'])
|
||||
@api_auth_required
|
||||
def remove_ip(site_id, ip_id):
|
||||
"""
|
||||
Remove an IP from a site.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
ip_id: IP ID
|
||||
|
||||
Returns:
|
||||
JSON response with success message
|
||||
"""
|
||||
try:
|
||||
site_service = SiteService(current_app.db_session)
|
||||
site_service.remove_ip_override(cidr_id, ip_id)
|
||||
site_service.remove_ip(site_id, ip_id)
|
||||
|
||||
logger.info(f"Removed IP override {ip_id} from CIDR {cidr_id} in site {site_id}")
|
||||
logger.info(f"Removed IP {ip_id} from site {site_id}")
|
||||
return jsonify({
|
||||
'message': f'IP override {ip_id} removed successfully'
|
||||
'message': f'IP {ip_id} removed successfully'
|
||||
})
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning(f"Cannot remove IP override {ip_id}: {str(e)}")
|
||||
logger.warning(f"Cannot remove IP {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)}")
|
||||
logger.error(f"Database error removing IP {ip_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to remove IP override'
|
||||
'message': 'Failed to remove IP'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error removing IP override {ip_id}: {str(e)}", exc_info=True)
|
||||
logger.error(f"Unexpected error removing IP {ip_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
|
||||
@@ -7,7 +7,7 @@ that are managed by developers, not stored in the database.
|
||||
|
||||
# Application metadata
|
||||
APP_NAME = 'SneakyScanner'
|
||||
APP_VERSION = '1.0.0-phase5'
|
||||
APP_VERSION = '1.0.0-alpha'
|
||||
|
||||
# Repository URL
|
||||
REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan'
|
||||
|
||||
@@ -267,7 +267,7 @@ class Site(Base):
|
||||
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')
|
||||
ips = relationship('SiteIP', back_populates='site', cascade='all, delete-orphan')
|
||||
scan_associations = relationship('ScanSiteAssociation', back_populates='site')
|
||||
config_associations = relationship('ScanConfigSite', back_populates='site')
|
||||
|
||||
@@ -275,59 +275,29 @@ class Site(Base):
|
||||
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.
|
||||
Individual IP addresses with their own settings.
|
||||
|
||||
Allows fine-grained control where specific IPs within a CIDR have
|
||||
different expectations than the CIDR-level defaults.
|
||||
Each IP is directly associated with a site and has its own port and ping settings.
|
||||
IPs are standalone entities - CIDRs are only used as a convenience for bulk creation.
|
||||
"""
|
||||
__tablename__ = 'site_ips'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
site_cidr_id = Column(Integer, ForeignKey('site_cidrs.id'), nullable=False, index=True)
|
||||
site_id = Column(Integer, ForeignKey('sites.id'), nullable=False, index=True, comment="FK to sites")
|
||||
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")
|
||||
expected_ping = Column(Boolean, nullable=True, comment="Expected ping response for this IP")
|
||||
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="IP creation time")
|
||||
|
||||
# Relationships
|
||||
cidr = relationship('SiteCIDR', back_populates='ips')
|
||||
site = relationship('Site', back_populates='ips')
|
||||
|
||||
# Index for efficient IP lookups
|
||||
# Index for efficient IP lookups - prevent duplicate IPs within a site
|
||||
__table_args__ = (
|
||||
UniqueConstraint('site_cidr_id', 'ip_address', name='uix_site_cidr_ip'),
|
||||
UniqueConstraint('site_id', 'ip_address', name='uix_site_ip_address'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -507,12 +477,13 @@ class AlertRule(Base):
|
||||
webhook_enabled = Column(Boolean, nullable=False, default=False, comment="Send webhook for this rule?")
|
||||
severity = Column(String(20), nullable=True, comment="Alert severity: critical, warning, info")
|
||||
filter_conditions = Column(Text, nullable=True, comment="JSON filter conditions for the rule")
|
||||
config_file = Column(String(255), nullable=True, comment="Optional: specific config file this rule applies to")
|
||||
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="Optional: specific config this rule applies to")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Rule creation time")
|
||||
updated_at = Column(DateTime, nullable=True, comment="Last update time")
|
||||
|
||||
# Relationships
|
||||
alerts = relationship("Alert", back_populates="rule", cascade="all, delete-orphan")
|
||||
config = relationship("ScanConfig", backref="alert_rules")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AlertRule(id={self.id}, name='{self.name}', rule_type='{self.rule_type}', enabled={self.enabled})>"
|
||||
|
||||
@@ -35,20 +35,7 @@ def dashboard():
|
||||
Returns:
|
||||
Rendered dashboard template
|
||||
"""
|
||||
import os
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template('dashboard.html', config_files=config_files)
|
||||
return render_template('dashboard.html')
|
||||
|
||||
|
||||
@bp.route('/scans')
|
||||
@@ -60,20 +47,7 @@ def scans():
|
||||
Returns:
|
||||
Rendered scans list template
|
||||
"""
|
||||
import os
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template('scans.html', config_files=config_files)
|
||||
return render_template('scans.html')
|
||||
|
||||
|
||||
@bp.route('/scans/<int:scan_id>')
|
||||
@@ -299,7 +273,6 @@ def alert_rules():
|
||||
Returns:
|
||||
Rendered alert rules template
|
||||
"""
|
||||
import os
|
||||
from flask import current_app
|
||||
from web.models import AlertRule
|
||||
|
||||
@@ -317,19 +290,7 @@ def alert_rules():
|
||||
if rules is None:
|
||||
rules = []
|
||||
|
||||
# Get list of available config files
|
||||
configs_dir = '/app/configs'
|
||||
config_files = []
|
||||
|
||||
try:
|
||||
if os.path.exists(configs_dir):
|
||||
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
|
||||
config_files.sort()
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing config files: {e}")
|
||||
|
||||
return render_template(
|
||||
'alert_rules.html',
|
||||
rules=rules,
|
||||
config_files=config_files
|
||||
rules=rules
|
||||
)
|
||||
|
||||
@@ -129,7 +129,7 @@ class ConfigService:
|
||||
'id': site.id,
|
||||
'name': site.name,
|
||||
'description': site.description,
|
||||
'cidr_count': len(site.cidrs)
|
||||
'ip_count': len(site.ips)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from web.models import (
|
||||
Site, SiteCIDR, SiteIP, ScanSiteAssociation
|
||||
Site, SiteIP, ScanSiteAssociation
|
||||
)
|
||||
from web.utils.pagination import paginate, PaginatedResult
|
||||
|
||||
@@ -40,34 +40,26 @@ class SiteService:
|
||||
"""
|
||||
self.db = db_session
|
||||
|
||||
def create_site(self, name: str, description: Optional[str] = None,
|
||||
cidrs: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
||||
def create_site(self, name: str, description: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new site with optional CIDR ranges.
|
||||
Create a new site.
|
||||
|
||||
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
|
||||
ValueError: If site name already exists
|
||||
"""
|
||||
# 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
|
||||
# Create site (can be empty, IPs added separately)
|
||||
site = Site(
|
||||
name=name,
|
||||
description=description,
|
||||
@@ -76,17 +68,10 @@ class SiteService:
|
||||
)
|
||||
|
||||
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)")
|
||||
logger.info(f"Created site '{name}' (id={site.id})")
|
||||
|
||||
return self._site_to_dict(site)
|
||||
|
||||
@@ -171,7 +156,7 @@ class SiteService:
|
||||
|
||||
def get_site(self, site_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get site details with all CIDRs and IP overrides.
|
||||
Get site details.
|
||||
|
||||
Args:
|
||||
site_id: Site ID to retrieve
|
||||
@@ -181,9 +166,6 @@ class SiteService:
|
||||
"""
|
||||
site = (
|
||||
self.db.query(Site)
|
||||
.options(
|
||||
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
|
||||
)
|
||||
.filter(Site.id == site_id)
|
||||
.first()
|
||||
)
|
||||
@@ -205,9 +187,6 @@ class SiteService:
|
||||
"""
|
||||
site = (
|
||||
self.db.query(Site)
|
||||
.options(
|
||||
joinedload(Site.cidrs).joinedload(SiteCIDR.ips)
|
||||
)
|
||||
.filter(Site.name == name)
|
||||
.first()
|
||||
)
|
||||
@@ -230,7 +209,6 @@ class SiteService:
|
||||
"""
|
||||
query = (
|
||||
self.db.query(Site)
|
||||
.options(joinedload(Site.cidrs))
|
||||
.order_by(Site.name)
|
||||
)
|
||||
|
||||
@@ -245,160 +223,211 @@ class SiteService:
|
||||
"""
|
||||
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]:
|
||||
def bulk_add_ips_from_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.
|
||||
Expand a CIDR range and add all IPs to a site.
|
||||
|
||||
CIDRs are NOT stored - they are just used to generate IP records.
|
||||
|
||||
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_ping: Expected ping response for all IPs
|
||||
expected_tcp_ports: List of expected TCP ports for all IPs
|
||||
expected_udp_ports: List of expected UDP ports for all IPs
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- cidr: The CIDR that was expanded
|
||||
- ip_count: Number of IPs created
|
||||
- ips_added: List of IP addresses created
|
||||
- ips_skipped: List of IPs that already existed
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found or CIDR is invalid/too large
|
||||
"""
|
||||
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 and size
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
|
||||
|
||||
# Enforce CIDR size limits (max /24 for IPv4, /64 for IPv6)
|
||||
if isinstance(network, ipaddress.IPv4Network) and network.prefixlen < 24:
|
||||
raise ValueError(
|
||||
f"CIDR '{cidr}' is too large ({network.num_addresses} IPs). "
|
||||
f"Maximum allowed is /24 (256 IPs) for IPv4."
|
||||
)
|
||||
elif isinstance(network, ipaddress.IPv6Network) and network.prefixlen < 64:
|
||||
raise ValueError(
|
||||
f"CIDR '{cidr}' is too large. "
|
||||
f"Maximum allowed is /64 for IPv6."
|
||||
)
|
||||
|
||||
# Expand CIDR to individual IPs (no cidr_id since we're not storing CIDR)
|
||||
ip_count, ips_added, ips_skipped = self._expand_cidr_to_ips(
|
||||
site_id=site_id,
|
||||
network=network,
|
||||
expected_ping=expected_ping,
|
||||
expected_tcp_ports=expected_tcp_ports or [],
|
||||
expected_udp_ports=expected_udp_ports or []
|
||||
)
|
||||
|
||||
site.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Expanded CIDR '{cidr}' for site {site_id} ('{site.name}'): "
|
||||
f"added {ip_count} IPs, skipped {len(ips_skipped)} duplicates"
|
||||
)
|
||||
|
||||
return {
|
||||
'cidr': cidr,
|
||||
'ip_count': ip_count,
|
||||
'ips_added': ips_added,
|
||||
'ips_skipped': ips_skipped
|
||||
}
|
||||
|
||||
def bulk_add_ips_from_list(self, site_id: int, ip_list: List[str],
|
||||
expected_ping: Optional[bool] = None,
|
||||
expected_tcp_ports: Optional[List[int]] = None,
|
||||
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Add multiple IPs from a list (e.g., from CSV/text import).
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
ip_list: List of IP addresses as strings
|
||||
expected_ping: Expected ping response for all IPs
|
||||
expected_tcp_ports: List of expected TCP ports for all IPs
|
||||
expected_udp_ports: List of expected UDP ports for all IPs
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- ip_count: Number of IPs successfully created
|
||||
- ips_added: List of IP addresses created
|
||||
- ips_skipped: List of IPs that already existed
|
||||
- errors: List of validation errors {ip: error_message}
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found
|
||||
"""
|
||||
site = self.db.query(Site).filter(Site.id == site_id).first()
|
||||
if not site:
|
||||
raise ValueError(f"Site with id {site_id} not found")
|
||||
|
||||
ips_added = []
|
||||
ips_skipped = []
|
||||
errors = []
|
||||
|
||||
for ip_str in ip_list:
|
||||
ip_str = ip_str.strip()
|
||||
if not ip_str:
|
||||
continue # Skip empty lines
|
||||
|
||||
# Validate IP format
|
||||
try:
|
||||
ipaddress.ip_address(ip_str)
|
||||
except ValueError as e:
|
||||
errors.append({'ip': ip_str, 'error': f"Invalid IP address: {str(e)}"})
|
||||
continue
|
||||
|
||||
# Check for duplicate (across all IPs in the site)
|
||||
existing = (
|
||||
self.db.query(SiteIP)
|
||||
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_str)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
ips_skipped.append(ip_str)
|
||||
continue
|
||||
|
||||
# Create IP record
|
||||
try:
|
||||
ip_obj = SiteIP(
|
||||
site_id=site_id,
|
||||
ip_address=ip_str,
|
||||
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_obj)
|
||||
ips_added.append(ip_str)
|
||||
except Exception as e:
|
||||
errors.append({'ip': ip_str, 'error': f"Database error: {str(e)}"})
|
||||
|
||||
site.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Bulk added {len(ips_added)} IPs to site {site_id} ('{site.name}'), "
|
||||
f"skipped {len(ips_skipped)} duplicates, {len(errors)} errors"
|
||||
)
|
||||
|
||||
return {
|
||||
'ip_count': len(ips_added),
|
||||
'ips_added': ips_added,
|
||||
'ips_skipped': ips_skipped,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
def add_standalone_ip(self, site_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 a standalone IP (without a CIDR parent) to a site.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
ip_address: IP address to add
|
||||
expected_ping: Expected ping response
|
||||
expected_tcp_ports: List of expected TCP ports
|
||||
expected_udp_ports: List of expected UDP ports
|
||||
|
||||
Returns:
|
||||
Dictionary with CIDR data
|
||||
Dictionary with IP data
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found, CIDR is invalid, or already exists
|
||||
ValueError: If site not found, IP 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)
|
||||
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
|
||||
# Check for duplicate (across all IPs in the site)
|
||||
existing = (
|
||||
self.db.query(SiteIP)
|
||||
.filter(SiteIP.site_cidr_id == cidr_id, SiteIP.ip_address == ip_address)
|
||||
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_address)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ValueError(f"IP override for '{ip_address}' already exists in this CIDR")
|
||||
raise ValueError(f"IP '{ip_address}' already exists in this site")
|
||||
|
||||
# Create IP override
|
||||
ip_override = SiteIP(
|
||||
site_cidr_id=cidr_id,
|
||||
# Create IP
|
||||
ip_obj = SiteIP(
|
||||
site_id=site_id,
|
||||
ip_address=ip_address,
|
||||
expected_ping=expected_ping,
|
||||
expected_tcp_ports=json.dumps(expected_tcp_ports or []),
|
||||
@@ -406,38 +435,102 @@ class SiteService:
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
self.db.add(ip_override)
|
||||
self.db.add(ip_obj)
|
||||
site.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(ip_override)
|
||||
self.db.refresh(ip_obj)
|
||||
|
||||
logger.info(f"Added IP override '{ip_address}' to CIDR {cidr_id} ('{cidr.cidr}')")
|
||||
logger.info(f"Added IP '{ip_address}' to site {site_id} ('{site.name}')")
|
||||
|
||||
return self._ip_override_to_dict(ip_override)
|
||||
return self._ip_to_dict(ip_obj)
|
||||
|
||||
def remove_ip_override(self, cidr_id: int, ip_id: int) -> None:
|
||||
def update_ip_settings(self, site_id: int, ip_id: int,
|
||||
expected_ping: Optional[bool] = None,
|
||||
expected_tcp_ports: Optional[List[int]] = None,
|
||||
expected_udp_ports: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Remove an IP-level override.
|
||||
Update settings for an individual IP.
|
||||
|
||||
Args:
|
||||
cidr_id: CIDR ID
|
||||
ip_id: IP override ID to remove
|
||||
site_id: Site ID
|
||||
ip_id: IP ID to update
|
||||
expected_ping: New ping expectation (if provided)
|
||||
expected_tcp_ports: New TCP ports expectation (if provided)
|
||||
expected_udp_ports: New UDP ports expectation (if provided)
|
||||
|
||||
Returns:
|
||||
Dictionary with updated IP data
|
||||
|
||||
Raises:
|
||||
ValueError: If IP override not found
|
||||
ValueError: If IP not found
|
||||
"""
|
||||
ip_override = (
|
||||
ip_obj = (
|
||||
self.db.query(SiteIP)
|
||||
.filter(SiteIP.id == ip_id, SiteIP.site_cidr_id == cidr_id)
|
||||
.filter(SiteIP.id == ip_id, SiteIP.site_id == site_id)
|
||||
.first()
|
||||
)
|
||||
if not ip_override:
|
||||
raise ValueError(f"IP override with id {ip_id} not found for CIDR {cidr_id}")
|
||||
if not ip_obj:
|
||||
raise ValueError(f"IP with id {ip_id} not found for site {site_id}")
|
||||
|
||||
ip_address = ip_override.ip_address
|
||||
self.db.delete(ip_override)
|
||||
# Update settings if provided
|
||||
if expected_ping is not None:
|
||||
ip_obj.expected_ping = expected_ping
|
||||
if expected_tcp_ports is not None:
|
||||
ip_obj.expected_tcp_ports = json.dumps(expected_tcp_ports)
|
||||
if expected_udp_ports is not None:
|
||||
ip_obj.expected_udp_ports = json.dumps(expected_udp_ports)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(ip_obj)
|
||||
|
||||
logger.info(f"Updated settings for IP '{ip_obj.ip_address}' in site {site_id}")
|
||||
|
||||
return self._ip_to_dict(ip_obj)
|
||||
|
||||
def remove_ip(self, site_id: int, ip_id: int) -> None:
|
||||
"""
|
||||
Remove an IP from a site.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
ip_id: IP ID to remove
|
||||
|
||||
Raises:
|
||||
ValueError: If IP not found
|
||||
"""
|
||||
ip_obj = (
|
||||
self.db.query(SiteIP)
|
||||
.filter(SiteIP.id == ip_id, SiteIP.site_id == site_id)
|
||||
.first()
|
||||
)
|
||||
if not ip_obj:
|
||||
raise ValueError(f"IP with id {ip_id} not found for site {site_id}")
|
||||
|
||||
ip_address = ip_obj.ip_address
|
||||
self.db.delete(ip_obj)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Removed IP override '{ip_address}' from CIDR {cidr_id}")
|
||||
logger.info(f"Removed IP '{ip_address}' from site {site_id}")
|
||||
|
||||
def list_ips(self, site_id: int, page: int = 1, per_page: int = 50) -> PaginatedResult:
|
||||
"""
|
||||
List IPs in a site with pagination.
|
||||
|
||||
Args:
|
||||
site_id: Site ID
|
||||
page: Page number (1-indexed)
|
||||
per_page: Number of items per page
|
||||
|
||||
Returns:
|
||||
PaginatedResult with IP data
|
||||
"""
|
||||
query = (
|
||||
self.db.query(SiteIP)
|
||||
.filter(SiteIP.site_id == site_id)
|
||||
.order_by(SiteIP.ip_address)
|
||||
)
|
||||
|
||||
return paginate(query, page, per_page, self._ip_to_dict)
|
||||
|
||||
def get_scan_usage(self, site_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -470,59 +563,91 @@ class SiteService:
|
||||
|
||||
# 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")
|
||||
def _expand_cidr_to_ips(self, site_id: int,
|
||||
network: ipaddress.IPv4Network | ipaddress.IPv6Network,
|
||||
expected_ping: Optional[bool],
|
||||
expected_tcp_ports: List[int],
|
||||
expected_udp_ports: List[int]) -> tuple[int, List[str], List[str]]:
|
||||
"""
|
||||
Expand a CIDR to individual IP addresses.
|
||||
|
||||
# Validate CIDR format
|
||||
try:
|
||||
ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid CIDR notation '{cidr}': {str(e)}")
|
||||
Args:
|
||||
site_id: Site ID
|
||||
network: ipaddress network object
|
||||
expected_ping: Default ping setting for all IPs
|
||||
expected_tcp_ports: Default TCP ports for all IPs
|
||||
expected_udp_ports: Default UDP ports for all IPs
|
||||
|
||||
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()
|
||||
)
|
||||
Returns:
|
||||
Tuple of (count of IPs created, list of IPs added, list of IPs skipped)
|
||||
"""
|
||||
ip_count = 0
|
||||
ips_added = []
|
||||
ips_skipped = []
|
||||
|
||||
self.db.add(cidr_obj)
|
||||
return cidr_obj
|
||||
# For /32 or /128 (single host), use the network address
|
||||
# For larger ranges, use hosts() to exclude network/broadcast addresses
|
||||
if network.num_addresses == 1:
|
||||
ip_list = [network.network_address]
|
||||
elif network.num_addresses == 2:
|
||||
# For /31 networks (point-to-point), both addresses are usable
|
||||
ip_list = [network.network_address, network.broadcast_address]
|
||||
else:
|
||||
# Use hosts() to get usable IPs (excludes network and broadcast)
|
||||
ip_list = list(network.hosts())
|
||||
|
||||
for ip in ip_list:
|
||||
ip_str = str(ip)
|
||||
|
||||
# Check for duplicate
|
||||
existing = (
|
||||
self.db.query(SiteIP)
|
||||
.filter(SiteIP.site_id == site_id, SiteIP.ip_address == ip_str)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
ips_skipped.append(ip_str)
|
||||
continue
|
||||
|
||||
# Create SiteIP entry
|
||||
ip_obj = SiteIP(
|
||||
site_id=site_id,
|
||||
ip_address=ip_str,
|
||||
expected_ping=expected_ping,
|
||||
expected_tcp_ports=json.dumps(expected_tcp_ports),
|
||||
expected_udp_ports=json.dumps(expected_udp_ports),
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
self.db.add(ip_obj)
|
||||
ips_added.append(ip_str)
|
||||
ip_count += 1
|
||||
|
||||
return ip_count, ips_added, ips_skipped
|
||||
|
||||
def _site_to_dict(self, site: Site) -> Dict[str, Any]:
|
||||
"""Convert Site model to dictionary."""
|
||||
# Count IPs for this site
|
||||
ip_count = (
|
||||
self.db.query(func.count(SiteIP.id))
|
||||
.filter(SiteIP.site_id == site.id)
|
||||
.scalar() or 0
|
||||
)
|
||||
|
||||
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 []
|
||||
'ip_count': ip_count
|
||||
}
|
||||
|
||||
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]:
|
||||
def _ip_to_dict(self, ip: SiteIP) -> Dict[str, Any]:
|
||||
"""Convert SiteIP model to dictionary."""
|
||||
return {
|
||||
'id': ip.id,
|
||||
'site_cidr_id': ip.site_cidr_id,
|
||||
'site_id': ip.site_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 [],
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if rule.config_file %}
|
||||
<small class="text-muted">{{ rule.config_file }}</small>
|
||||
{% if rule.config %}
|
||||
<small class="text-muted">{{ rule.config.title }}</small>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">All Configs</span>
|
||||
{% endif %}
|
||||
@@ -209,20 +209,9 @@
|
||||
<label for="rule-config" class="form-label">Apply to Config (optional)</label>
|
||||
<select class="form-select" id="rule-config">
|
||||
<option value="">All Configs (Apply to all scans)</option>
|
||||
{% if config_files %}
|
||||
{% for config_file in config_files %}
|
||||
<option value="{{ config_file }}">{{ config_file }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="" disabled>No config files found</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
{% if config_files %}
|
||||
Select a specific config file to limit this rule, or leave as "All Configs" to apply to all scans
|
||||
{% else %}
|
||||
No config files found. Upload a config in the Configs section to see available options.
|
||||
{% endif %}
|
||||
<small class="form-text text-muted" id="config-help-text">
|
||||
Select a specific config to limit this rule, or leave as "All Configs" to apply to all scans
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,12 +261,51 @@
|
||||
<script>
|
||||
let editingRuleId = null;
|
||||
|
||||
// Load available configs for the dropdown
|
||||
async function loadConfigsForRule() {
|
||||
const selectEl = document.getElementById('rule-config');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/configs');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configurations');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const configs = data.configs || [];
|
||||
|
||||
// Preserve the "All Configs" option and current selection
|
||||
const currentValue = selectEl.value;
|
||||
selectEl.innerHTML = '<option value="">All Configs (Apply to all scans)</option>';
|
||||
|
||||
configs.forEach(config => {
|
||||
const option = document.createElement('option');
|
||||
// Store the config ID as the value
|
||||
option.value = config.id;
|
||||
const siteText = config.site_count === 1 ? 'site' : 'sites';
|
||||
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
|
||||
// Restore selection if it was set
|
||||
if (currentValue) {
|
||||
selectEl.value = currentValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading configs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateRuleModal() {
|
||||
editingRuleId = null;
|
||||
document.getElementById('ruleModalTitle').textContent = 'Create Alert Rule';
|
||||
document.getElementById('save-rule-text').textContent = 'Create Rule';
|
||||
document.getElementById('ruleForm').reset();
|
||||
document.getElementById('rule-enabled').checked = true;
|
||||
|
||||
// Load configs when modal is shown
|
||||
loadConfigsForRule();
|
||||
|
||||
new bootstrap.Modal(document.getElementById('ruleModal')).show();
|
||||
}
|
||||
|
||||
@@ -286,33 +314,36 @@ function editRule(ruleId) {
|
||||
document.getElementById('ruleModalTitle').textContent = 'Edit Alert Rule';
|
||||
document.getElementById('save-rule-text').textContent = 'Update Rule';
|
||||
|
||||
// Fetch rule details
|
||||
fetch(`/api/alerts/rules`, {
|
||||
headers: {
|
||||
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const rule = data.rules.find(r => r.id === ruleId);
|
||||
if (rule) {
|
||||
document.getElementById('rule-id').value = rule.id;
|
||||
document.getElementById('rule-name').value = rule.name || '';
|
||||
document.getElementById('rule-type').value = rule.rule_type;
|
||||
document.getElementById('rule-severity').value = rule.severity || 'warning';
|
||||
document.getElementById('rule-threshold').value = rule.threshold || '';
|
||||
document.getElementById('rule-config').value = rule.config_file || '';
|
||||
document.getElementById('rule-email').checked = rule.email_enabled;
|
||||
document.getElementById('rule-webhook').checked = rule.webhook_enabled;
|
||||
document.getElementById('rule-enabled').checked = rule.enabled;
|
||||
// Load configs first, then fetch rule details
|
||||
loadConfigsForRule().then(() => {
|
||||
// Fetch rule details
|
||||
fetch(`/api/alerts/rules`, {
|
||||
headers: {
|
||||
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const rule = data.rules.find(r => r.id === ruleId);
|
||||
if (rule) {
|
||||
document.getElementById('rule-id').value = rule.id;
|
||||
document.getElementById('rule-name').value = rule.name || '';
|
||||
document.getElementById('rule-type').value = rule.rule_type;
|
||||
document.getElementById('rule-severity').value = rule.severity || 'warning';
|
||||
document.getElementById('rule-threshold').value = rule.threshold || '';
|
||||
document.getElementById('rule-config').value = rule.config_id || '';
|
||||
document.getElementById('rule-email').checked = rule.email_enabled;
|
||||
document.getElementById('rule-webhook').checked = rule.webhook_enabled;
|
||||
document.getElementById('rule-enabled').checked = rule.enabled;
|
||||
|
||||
updateThresholdLabel();
|
||||
new bootstrap.Modal(document.getElementById('ruleModal')).show();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching rule:', error);
|
||||
alert('Failed to load rule details');
|
||||
updateThresholdLabel();
|
||||
new bootstrap.Modal(document.getElementById('ruleModal')).show();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching rule:', error);
|
||||
alert('Failed to load rule details');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,7 +384,7 @@ function saveRule() {
|
||||
const ruleType = document.getElementById('rule-type').value;
|
||||
const severity = document.getElementById('rule-severity').value;
|
||||
const threshold = document.getElementById('rule-threshold').value;
|
||||
const configFile = document.getElementById('rule-config').value;
|
||||
const configId = document.getElementById('rule-config').value;
|
||||
const emailEnabled = document.getElementById('rule-email').checked;
|
||||
const webhookEnabled = document.getElementById('rule-webhook').checked;
|
||||
const enabled = document.getElementById('rule-enabled').checked;
|
||||
@@ -368,7 +399,7 @@ function saveRule() {
|
||||
rule_type: ruleType,
|
||||
severity: severity,
|
||||
threshold: threshold ? parseInt(threshold) : null,
|
||||
config_file: configFile || null,
|
||||
config_id: configId ? parseInt(configId) : null,
|
||||
email_enabled: emailEnabled,
|
||||
webhook_enabled: webhookEnabled,
|
||||
enabled: enabled
|
||||
|
||||
@@ -295,7 +295,7 @@ function renderSitesCheckboxes(selectedIds = [], isEditMode = false) {
|
||||
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>
|
||||
<small class="text-muted">(${site.ip_count || 0} IP${site.ip_count !== 1 ? 's' : ''})</small>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
@@ -451,7 +451,7 @@ async function viewConfig(id) {
|
||||
<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>
|
||||
<li>${escapeHtml(site.name)} <small class="text-muted">(${site.ip_count} IP${site.ip_count !== 1 ? 's' : ''})</small></li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -153,34 +153,28 @@
|
||||
<div class="modal-body">
|
||||
<form id="trigger-scan-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Config File</label>
|
||||
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
<label for="config-select" class="form-label">Scan Configuration</label>
|
||||
<select class="form-select" id="config-select" name="config_id" required>
|
||||
<option value="">Loading configurations...</option>
|
||||
</select>
|
||||
{% if config_files %}
|
||||
<div class="form-text text-muted">
|
||||
Select a scan configuration file
|
||||
<div class="form-text text-muted" id="config-help-text">
|
||||
Select a scan configuration
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mt-2 mb-0" role="alert">
|
||||
<div id="no-configs-warning" class="alert alert-warning mt-2 mb-0" role="alert" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>No configurations available</strong>
|
||||
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
|
||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
|
||||
<p class="mb-2 mt-2">You need to create a configuration before you can trigger a scan.</p>
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></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="triggerScan()" {% if not config_files %}disabled{% endif %}>
|
||||
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
|
||||
<span id="modal-trigger-text">Trigger Scan</span>
|
||||
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||
</button>
|
||||
@@ -323,23 +317,75 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Load available configs
|
||||
async function loadConfigs() {
|
||||
const selectEl = document.getElementById('config-select');
|
||||
const helpTextEl = document.getElementById('config-help-text');
|
||||
const noConfigsWarning = document.getElementById('no-configs-warning');
|
||||
const triggerBtn = document.getElementById('trigger-scan-btn');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/configs');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configurations');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const configs = data.configs || [];
|
||||
|
||||
// Clear existing options
|
||||
selectEl.innerHTML = '';
|
||||
|
||||
if (configs.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No configurations available</option>';
|
||||
selectEl.disabled = true;
|
||||
triggerBtn.disabled = true;
|
||||
helpTextEl.style.display = 'none';
|
||||
noConfigsWarning.style.display = 'block';
|
||||
} else {
|
||||
selectEl.innerHTML = '<option value="">Select a configuration...</option>';
|
||||
configs.forEach(config => {
|
||||
const option = document.createElement('option');
|
||||
option.value = config.id;
|
||||
const siteText = config.site_count === 1 ? 'site' : 'sites';
|
||||
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
selectEl.disabled = false;
|
||||
triggerBtn.disabled = false;
|
||||
helpTextEl.style.display = 'block';
|
||||
noConfigsWarning.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading configs:', error);
|
||||
selectEl.innerHTML = '<option value="">Error loading configurations</option>';
|
||||
selectEl.disabled = true;
|
||||
triggerBtn.disabled = true;
|
||||
helpTextEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show trigger scan modal
|
||||
function showTriggerScanModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
|
||||
document.getElementById('trigger-error').style.display = 'none';
|
||||
document.getElementById('trigger-scan-form').reset();
|
||||
|
||||
// Load configs when modal is shown
|
||||
loadConfigs();
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Trigger scan
|
||||
async function triggerScan() {
|
||||
const configFile = document.getElementById('config-file').value;
|
||||
const configId = document.getElementById('config-select').value;
|
||||
const errorEl = document.getElementById('trigger-error');
|
||||
const btnText = document.getElementById('modal-trigger-text');
|
||||
const btnSpinner = document.getElementById('modal-trigger-spinner');
|
||||
|
||||
if (!configFile) {
|
||||
errorEl.textContent = 'Please enter a config file path.';
|
||||
if (!configId) {
|
||||
errorEl.textContent = 'Please select a configuration.';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
@@ -356,7 +402,7 @@
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
config_file: configFile
|
||||
config_id: parseInt(configId)
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -79,14 +79,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-0">
|
||||
<label class="form-label text-muted">Config File</label>
|
||||
<div id="scan-config-file" class="mono">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,34 +113,28 @@
|
||||
<div class="modal-body">
|
||||
<form id="trigger-scan-form">
|
||||
<div class="mb-3">
|
||||
<label for="config-file" class="form-label">Config File</label>
|
||||
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
|
||||
<option value="">Select a config file...</option>
|
||||
{% for config in config_files %}
|
||||
<option value="{{ config }}">{{ config }}</option>
|
||||
{% endfor %}
|
||||
<label for="config-select" class="form-label">Scan Configuration</label>
|
||||
<select class="form-select" id="config-select" name="config_id" required>
|
||||
<option value="">Loading configurations...</option>
|
||||
</select>
|
||||
{% if config_files %}
|
||||
<div class="form-text text-muted">
|
||||
Select a scan configuration file
|
||||
<div class="form-text text-muted" id="config-help-text">
|
||||
Select a scan configuration
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mt-2 mb-0" role="alert">
|
||||
<div id="no-configs-warning" class="alert alert-warning mt-2 mb-0" role="alert" style="display: none;">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>No configurations available</strong>
|
||||
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
|
||||
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
|
||||
<p class="mb-2 mt-2">You need to create a configuration before you can trigger a scan.</p>
|
||||
<a href="{{ url_for('main.configs') }}" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></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="triggerScan()" {% if not config_files %}disabled{% endif %}>
|
||||
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
|
||||
<span id="modal-trigger-text">Trigger Scan</span>
|
||||
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||
</button>
|
||||
@@ -359,23 +353,75 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Load available configs
|
||||
async function loadConfigs() {
|
||||
const selectEl = document.getElementById('config-select');
|
||||
const helpTextEl = document.getElementById('config-help-text');
|
||||
const noConfigsWarning = document.getElementById('no-configs-warning');
|
||||
const triggerBtn = document.getElementById('trigger-scan-btn');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/configs');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load configurations');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const configs = data.configs || [];
|
||||
|
||||
// Clear existing options
|
||||
selectEl.innerHTML = '';
|
||||
|
||||
if (configs.length === 0) {
|
||||
selectEl.innerHTML = '<option value="">No configurations available</option>';
|
||||
selectEl.disabled = true;
|
||||
triggerBtn.disabled = true;
|
||||
helpTextEl.style.display = 'none';
|
||||
noConfigsWarning.style.display = 'block';
|
||||
} else {
|
||||
selectEl.innerHTML = '<option value="">Select a configuration...</option>';
|
||||
configs.forEach(config => {
|
||||
const option = document.createElement('option');
|
||||
option.value = config.id;
|
||||
const siteText = config.site_count === 1 ? 'site' : 'sites';
|
||||
option.textContent = `${config.title} (${config.site_count} ${siteText})`;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
selectEl.disabled = false;
|
||||
triggerBtn.disabled = false;
|
||||
helpTextEl.style.display = 'block';
|
||||
noConfigsWarning.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading configs:', error);
|
||||
selectEl.innerHTML = '<option value="">Error loading configurations</option>';
|
||||
selectEl.disabled = true;
|
||||
triggerBtn.disabled = true;
|
||||
helpTextEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show trigger scan modal
|
||||
function showTriggerScanModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
|
||||
document.getElementById('trigger-error').style.display = 'none';
|
||||
document.getElementById('trigger-scan-form').reset();
|
||||
|
||||
// Load configs when modal is shown
|
||||
loadConfigs();
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Trigger scan
|
||||
async function triggerScan() {
|
||||
const configFile = document.getElementById('config-file').value;
|
||||
const configId = document.getElementById('config-select').value;
|
||||
const errorEl = document.getElementById('trigger-error');
|
||||
const btnText = document.getElementById('modal-trigger-text');
|
||||
const btnSpinner = document.getElementById('modal-trigger-spinner');
|
||||
|
||||
if (!configFile) {
|
||||
errorEl.textContent = 'Please enter a config file path.';
|
||||
if (!configId) {
|
||||
errorEl.textContent = 'Please select a configuration.';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
@@ -392,13 +438,13 @@
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
config_file: configFile
|
||||
config_id: parseInt(configId)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to trigger scan');
|
||||
throw new Error(data.message || data.error || 'Failed to trigger scan');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
</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 class="stat-value" id="total-ips">-</div>
|
||||
<div class="stat-label">Total IPs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@@ -66,7 +66,7 @@
|
||||
<tr>
|
||||
<th>Site Name</th>
|
||||
<th>Description</th>
|
||||
<th>CIDRs</th>
|
||||
<th>IPs</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@@ -79,7 +79,7 @@
|
||||
<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>
|
||||
<p class="text-muted">Create your first site to organize your IP addresses</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>
|
||||
@@ -109,17 +109,11 @@
|
||||
</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>
|
||||
<textarea class="form-control" id="site-description" rows="3"
|
||||
placeholder="Optional description of this site"></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 class="alert alert-info" style="background-color: #1e3a5f; border-color: #2d5a8c; color: #a5d6ff;">
|
||||
<i class="bi bi-info-circle"></i> After creating the site, you'll be able to add IP addresses using CIDRs, individual IPs, or bulk import.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -147,9 +141,146 @@
|
||||
<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 class="d-flex justify-content-between align-items-center mt-3 mb-2">
|
||||
<h6 style="color: #94a3b8; margin: 0;">IP Addresses (<span id="ip-count">0</span>):</h6>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-plus-circle"></i> Add IPs
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('cidr'); return false;"><i class="bi bi-diagram-3"></i> From CIDR</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('individual'); return false;"><i class="bi bi- hdd-network"></i> Individual IP</a></li>
|
||||
<li><a class="dropdown-item" href="#" onclick="showAddIpMethod('bulk'); return false;"><i class="bi bi-file-earmark-text"></i> Bulk Import</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add from CIDR Form -->
|
||||
<div id="add-cidr-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
|
||||
<h6 style="color: #60a5fa;"><i class="bi bi-diagram-3"></i> Add IPs from CIDR</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">CIDR Range *</label>
|
||||
<input type="text" class="form-control form-control-sm" id="bulk-cidr" placeholder="e.g., 10.0.0.0/24">
|
||||
<small style="color: #64748b;">Max /24 (256 IPs)</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
|
||||
<select class="form-select form-select-sm" id="bulk-cidr-ping">
|
||||
<option value="null">Not Set</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false" selected>No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
|
||||
<input type="text" class="form-control form-control-sm" id="bulk-cidr-tcp-ports" placeholder="e.g., 22,80,443">
|
||||
<small style="color: #64748b;">Comma-separated</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
|
||||
<input type="text" class="form-control form-control-sm" id="bulk-cidr-udp-ports" placeholder="e.g., 53,123">
|
||||
<small style="color: #64748b;">Comma-separated</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" onclick="addIpsFromCidr()">
|
||||
<i class="bi bi-check-circle"></i> Add IPs
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Individual IP Form -->
|
||||
<div id="add-individual-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
|
||||
<h6 style="color: #60a5fa;"><i class="bi bi-hdd-network"></i> Add Individual IP</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">IP Address *</label>
|
||||
<input type="text" class="form-control form-control-sm" id="individual-ip" placeholder="e.g., 192.168.1.100">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
|
||||
<select class="form-select form-select-sm" id="individual-ping">
|
||||
<option value="null">Not Set</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false" selected>No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
|
||||
<input type="text" class="form-control form-control-sm" id="individual-tcp-ports" placeholder="e.g., 22,80,443">
|
||||
<small style="color: #64748b;">Comma-separated</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
|
||||
<input type="text" class="form-control form-control-sm" id="individual-udp-ports" placeholder="e.g., 53,123">
|
||||
<small style="color: #64748b;">Comma-separated</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" onclick="addIndividualIp()">
|
||||
<i class="bi bi-check-circle"></i> Add IP
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Import Form -->
|
||||
<div id="add-bulk-form" style="display: none; margin-bottom: 15px; padding: 15px; background-color: #0f172a; border: 1px solid #334155; border-radius: 5px;">
|
||||
<h6 style="color: #60a5fa;"><i class="bi bi-file-earmark-text"></i> Bulk Import IPs</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">IP Addresses *</label>
|
||||
<textarea class="form-control form-control-sm" id="bulk-ips" rows="5" placeholder="Paste IPs here (one per line, or comma/space separated)"></textarea>
|
||||
<small style="color: #64748b;">Supports: one per line, comma-separated, or space-separated</small>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected Ping</label>
|
||||
<select class="form-select form-select-sm" id="bulk-ping">
|
||||
<option value="null">Not Set</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false" selected>No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected TCP Ports</label>
|
||||
<input type="text" class="form-control form-control-sm" id="bulk-tcp-ports" placeholder="e.g., 22,80,443">
|
||||
<small style="color: #64748b;">Comma-separated</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" style="color: #e2e8f0; font-size: 0.875rem;">Expected UDP Ports</label>
|
||||
<input type="text" class="form-control form-control-sm" id="bulk-udp-ports" placeholder="e.g., 53,123">
|
||||
<small style="color: #64748b;">Comma-separated</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" onclick="addIpsFromBulk()">
|
||||
<i class="bi bi-check-circle"></i> Import IPs
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="hideAllAddForms()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Table -->
|
||||
<div id="view-site-ips-container">
|
||||
<div id="ips-loading" style="display: none;" class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span class="ms-2" style="color: #94a3b8;">Loading IPs...</span>
|
||||
</div>
|
||||
<div id="view-site-ips" class="table-responsive">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-3" style="color: #94a3b8;">Usage:</h6>
|
||||
@@ -188,7 +319,7 @@
|
||||
</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.
|
||||
<i class="bi bi-info-circle"></i> To manage IP addresses, use the "View" button on the site.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger" id="edit-site-error" style="display: none;">
|
||||
@@ -205,6 +336,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit IP Modal -->
|
||||
<div class="modal fade" id="editIpModal" 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: #60a5fa;">
|
||||
<i class="bi bi-pencil"></i> Edit IP Settings
|
||||
</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-ip-site-id">
|
||||
<input type="hidden" id="edit-ip-id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="color: #e2e8f0;">IP Address</label>
|
||||
<input type="text" class="form-control" id="edit-ip-address" readonly>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit-ip-ping" class="form-label" style="color: #e2e8f0;">Expected Ping</label>
|
||||
<select class="form-select" id="edit-ip-ping">
|
||||
<option value="null">Not Set</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit-ip-tcp-ports" class="form-label" style="color: #e2e8f0;">Expected TCP Ports</label>
|
||||
<input type="text" class="form-control" id="edit-ip-tcp-ports" placeholder="e.g., 22,80,443">
|
||||
<small style="color: #64748b;">Comma-separated port numbers</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit-ip-udp-ports" class="form-label" style="color: #e2e8f0;">Expected UDP Ports</label>
|
||||
<input type="text" class="form-control" id="edit-ip-udp-ports" placeholder="e.g., 53,123">
|
||||
<small style="color: #64748b;">Comma-separated port numbers</small>
|
||||
</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-ip-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">
|
||||
@@ -240,7 +421,31 @@
|
||||
// Global variables
|
||||
let sitesData = [];
|
||||
let selectedSiteForDeletion = null;
|
||||
let cidrInputCounter = 0;
|
||||
let currentViewingSiteId = null; // Track the site ID currently being viewed
|
||||
let currentSitePage = 1;
|
||||
|
||||
// Helper function to clean up any stray modal backdrops
|
||||
function cleanupModalBackdrops() {
|
||||
// Remove any leftover backdrops
|
||||
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
||||
backdrop.remove();
|
||||
});
|
||||
// Remove modal-open class from body
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('overflow');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
}
|
||||
|
||||
// Helper function to show a modal (reuses existing instance if available)
|
||||
function showModal(modalId) {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
let modal = bootstrap.Modal.getInstance(modalElement);
|
||||
if (!modal) {
|
||||
modal = new bootstrap.Modal(modalElement);
|
||||
}
|
||||
modal.show();
|
||||
return modal;
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(timestamp) {
|
||||
@@ -249,57 +454,26 @@ function formatDate(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);
|
||||
// Show/hide add IP form methods
|
||||
function showAddIpMethod(method) {
|
||||
hideAllAddForms();
|
||||
if (method === 'cidr') {
|
||||
document.getElementById('add-cidr-form').style.display = 'block';
|
||||
} else if (method === 'individual') {
|
||||
document.getElementById('add-individual-form').style.display = 'block';
|
||||
} else if (method === 'bulk') {
|
||||
document.getElementById('add-bulk-form').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Remove CIDR input field
|
||||
function removeCidrInput(id) {
|
||||
const element = document.getElementById(`cidr-${id}`);
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
function hideAllAddForms() {
|
||||
document.getElementById('add-cidr-form').style.display = 'none';
|
||||
document.getElementById('add-individual-form').style.display = 'none';
|
||||
document.getElementById('add-bulk-form').style.display = 'none';
|
||||
// Reset forms
|
||||
document.getElementById('bulk-cidr').value = '';
|
||||
document.getElementById('individual-ip').value = '';
|
||||
document.getElementById('bulk-ips').value = '';
|
||||
}
|
||||
|
||||
// Parse port list from comma-separated string
|
||||
@@ -342,10 +516,10 @@ async function loadSites() {
|
||||
// Update summary stats
|
||||
function updateStats() {
|
||||
const totalSites = sitesData.length;
|
||||
const totalCidrs = sitesData.reduce((sum, site) => sum + (site.cidrs?.length || 0), 0);
|
||||
const totalIps = sitesData.reduce((sum, site) => sum + (site.ip_count || 0), 0);
|
||||
|
||||
document.getElementById('total-sites').textContent = totalSites;
|
||||
document.getElementById('total-cidrs').textContent = totalCidrs;
|
||||
document.getElementById('total-ips').textContent = totalIps;
|
||||
document.getElementById('sites-in-use').textContent = '-'; // Will be updated async
|
||||
|
||||
// Count sites in use (async)
|
||||
@@ -380,16 +554,16 @@ function renderSites(sites) {
|
||||
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>';
|
||||
const ipCount = site.ip_count || 0;
|
||||
const ipBadge = ipCount > 0
|
||||
? `<span class="badge bg-info">${ipCount} IP${ipCount !== 1 ? 's' : ''}</span>`
|
||||
: '<span class="badge bg-secondary">No IPs</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>${ipBadge}</td>
|
||||
<td>${formatDate(site.created_at)}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
@@ -420,31 +594,6 @@ async function createSite() {
|
||||
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: {
|
||||
@@ -452,8 +601,7 @@ async function createSite() {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
description: description || null,
|
||||
cidrs: cidrs
|
||||
description: description || null
|
||||
})
|
||||
});
|
||||
|
||||
@@ -462,19 +610,22 @@ async function createSite() {
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
const newSite = await response.json();
|
||||
|
||||
// Hide create 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`);
|
||||
// Show success message and open view modal
|
||||
showAlert('success', `Site "${name}" created successfully. Now add some IP addresses!`);
|
||||
|
||||
// Open the view modal to add IPs
|
||||
viewSite(newSite.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating site:', error);
|
||||
@@ -485,6 +636,7 @@ async function createSite() {
|
||||
// View site details
|
||||
async function viewSite(siteId) {
|
||||
try {
|
||||
currentViewingSiteId = siteId;
|
||||
const response = await fetch(`/api/sites/${siteId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load site: ${response.statusText}`);
|
||||
@@ -495,31 +647,8 @@ async function viewSite(siteId) {
|
||||
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 IPs
|
||||
await loadSiteIps(siteId);
|
||||
|
||||
// Load usage
|
||||
document.getElementById('view-site-usage').innerHTML = '<p style="color: #94a3b8;"><i class="bi bi-hourglass"></i> Loading usage...</p>';
|
||||
@@ -538,7 +667,7 @@ async function viewSite(siteId) {
|
||||
document.getElementById('view-site-usage').innerHTML = usageHtml;
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('viewSiteModal')).show();
|
||||
showModal('viewSiteModal');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error viewing site:', error);
|
||||
@@ -546,6 +675,250 @@ async function viewSite(siteId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load IPs for a site
|
||||
async function loadSiteIps(siteId) {
|
||||
try {
|
||||
document.getElementById('ips-loading').style.display = 'block';
|
||||
|
||||
const response = await fetch(`/api/sites/${siteId}/ips?per_page=100`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load IPs');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const ips = data.ips || [];
|
||||
|
||||
document.getElementById('ip-count').textContent = data.total || ips.length;
|
||||
|
||||
// Render flat IP table
|
||||
if (ips.length === 0) {
|
||||
document.getElementById('view-site-ips').innerHTML = `
|
||||
<div class="text-center py-4" style="color: #94a3b8;">
|
||||
<i class="bi bi-hdd-network" style="font-size: 2rem;"></i>
|
||||
<p class="mt-2"><em>No IPs added yet</em></p>
|
||||
<p class="text-muted" style="font-size: 0.875rem;">Use the "Add IPs" button above to get started</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const tableHtml = `
|
||||
<table class="table table-sm table-hover">
|
||||
<thead style="position: sticky; top: 0; background-color: #1e293b;">
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Ping</th>
|
||||
<th>TCP Ports</th>
|
||||
<th>UDP Ports</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${ips.map(ip => `
|
||||
<tr>
|
||||
<td><code>${ip.ip_address}</code></td>
|
||||
<td>${ip.expected_ping ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'}</td>
|
||||
<td style="font-size: 0.875rem;">${ip.expected_tcp_ports?.length > 0 ? ip.expected_tcp_ports.join(', ') : '-'}</td>
|
||||
<td style="font-size: 0.875rem;">${ip.expected_udp_ports?.length > 0 ? ip.expected_udp_ports.join(', ') : '-'}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-primary" onclick="editIp(${siteId}, ${ip.id}, '${ip.ip_address}', ${ip.expected_ping}, ${JSON.stringify(ip.expected_tcp_ports || [])}, ${JSON.stringify(ip.expected_udp_ports || [])})" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" onclick="confirmDeleteIp(${siteId}, ${ip.id}, '${ip.ip_address}')" title="Delete IP">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
document.getElementById('view-site-ips').innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
document.getElementById('ips-loading').style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading IPs:', error);
|
||||
document.getElementById('view-site-ips').innerHTML = `<p style="color: #f87171;">Error loading IPs: ${error.message}</p>`;
|
||||
document.getElementById('ips-loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Add IPs from CIDR
|
||||
async function addIpsFromCidr() {
|
||||
try {
|
||||
const cidr = document.getElementById('bulk-cidr').value.trim();
|
||||
if (!cidr) {
|
||||
showAlert('warning', 'CIDR is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const pingValue = document.getElementById('bulk-cidr-ping').value;
|
||||
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
|
||||
const expectedTcpPorts = parsePortList(document.getElementById('bulk-cidr-tcp-ports').value);
|
||||
const expectedUdpPorts = parsePortList(document.getElementById('bulk-cidr-udp-ports').value);
|
||||
|
||||
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_type: 'cidr',
|
||||
cidr: cidr,
|
||||
expected_ping: expectedPing,
|
||||
expected_tcp_ports: expectedTcpPorts,
|
||||
expected_udp_ports: expectedUdpPorts
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
hideAllAddForms();
|
||||
await loadSiteIps(currentViewingSiteId);
|
||||
await loadSites(); // Refresh stats
|
||||
|
||||
showAlert('success', `Added ${result.ip_count} IPs from CIDR ${cidr}${result.ips_skipped.length > 0 ? ` (${result.ips_skipped.length} duplicates skipped)` : ''}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding IPs from CIDR:', error);
|
||||
showAlert('danger', `Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add individual IP
|
||||
async function addIndividualIp() {
|
||||
try {
|
||||
const ipAddress = document.getElementById('individual-ip').value.trim();
|
||||
if (!ipAddress) {
|
||||
showAlert('warning', 'IP address is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const pingValue = document.getElementById('individual-ping').value;
|
||||
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
|
||||
const expectedTcpPorts = parsePortList(document.getElementById('individual-tcp-ports').value);
|
||||
const expectedUdpPorts = parsePortList(document.getElementById('individual-udp-ports').value);
|
||||
|
||||
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ip_address: ipAddress,
|
||||
expected_ping: expectedPing,
|
||||
expected_tcp_ports: expectedTcpPorts,
|
||||
expected_udp_ports: expectedUdpPorts
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
hideAllAddForms();
|
||||
await loadSiteIps(currentViewingSiteId);
|
||||
await loadSites(); // Refresh stats
|
||||
|
||||
showAlert('success', `IP ${ipAddress} added successfully`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding IP:', error);
|
||||
showAlert('danger', `Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add IPs from bulk import
|
||||
async function addIpsFromBulk() {
|
||||
try {
|
||||
const bulkText = document.getElementById('bulk-ips').value.trim();
|
||||
if (!bulkText) {
|
||||
showAlert('warning', 'IP list is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse IPs from text (supports newlines, commas, spaces)
|
||||
const ipList = bulkText.split(/[\n,\s]+/).map(ip => ip.trim()).filter(ip => ip);
|
||||
|
||||
if (ipList.length === 0) {
|
||||
showAlert('warning', 'No valid IPs found');
|
||||
return;
|
||||
}
|
||||
|
||||
const pingValue = document.getElementById('bulk-ping').value;
|
||||
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
|
||||
const expectedTcpPorts = parsePortList(document.getElementById('bulk-tcp-ports').value);
|
||||
const expectedUdpPorts = parsePortList(document.getElementById('bulk-udp-ports').value);
|
||||
|
||||
const response = await fetch(`/api/sites/${currentViewingSiteId}/ips/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_type: 'list',
|
||||
ips: ipList,
|
||||
expected_ping: expectedPing,
|
||||
expected_tcp_ports: expectedTcpPorts,
|
||||
expected_udp_ports: expectedUdpPorts
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
hideAllAddForms();
|
||||
await loadSiteIps(currentViewingSiteId);
|
||||
await loadSites(); // Refresh stats
|
||||
|
||||
let message = `Added ${result.ip_count} IPs`;
|
||||
if (result.ips_skipped.length > 0) message += ` (${result.ips_skipped.length} duplicates skipped)`;
|
||||
if (result.errors.length > 0) message += ` (${result.errors.length} errors)`;
|
||||
|
||||
showAlert(result.errors.length > 0 ? 'warning' : 'success', message);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding IPs from bulk:', error);
|
||||
showAlert('danger', `Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm delete IP
|
||||
function confirmDeleteIp(siteId, ipId, ipAddress) {
|
||||
if (confirm(`Are you sure you want to delete IP ${ipAddress}?`)) {
|
||||
deleteIp(siteId, ipId);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete IP
|
||||
async function deleteIp(siteId, ipId) {
|
||||
try {
|
||||
const response = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
await loadSiteIps(siteId);
|
||||
await loadSites(); // Refresh stats
|
||||
|
||||
showAlert('success', 'IP deleted successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting IP:', error);
|
||||
showAlert('danger', `Error deleting IP: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit site
|
||||
async function editSite(siteId) {
|
||||
try {
|
||||
@@ -563,7 +936,7 @@ async function editSite(siteId) {
|
||||
document.getElementById('edit-site-error').style.display = 'none';
|
||||
|
||||
// Show modal
|
||||
new bootstrap.Modal(document.getElementById('editSiteModal')).show();
|
||||
showModal('editSiteModal');
|
||||
} catch (error) {
|
||||
console.error('Error loading site:', error);
|
||||
showAlert('danger', `Error loading site: ${error.message}`);
|
||||
@@ -638,7 +1011,7 @@ async function confirmDelete(siteId, siteName) {
|
||||
console.error('Error checking site usage:', e);
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
showModal('deleteModal');
|
||||
}
|
||||
|
||||
// Delete site
|
||||
@@ -670,6 +1043,69 @@ async function deleteSite() {
|
||||
}
|
||||
}
|
||||
|
||||
// Edit IP settings
|
||||
function editIp(siteId, ipId, ipAddress, expectedPing, expectedTcpPorts, expectedUdpPorts) {
|
||||
// Populate modal
|
||||
document.getElementById('edit-ip-site-id').value = siteId;
|
||||
document.getElementById('edit-ip-id').value = ipId;
|
||||
document.getElementById('edit-ip-address').value = ipAddress;
|
||||
|
||||
// Set ping value
|
||||
const pingValue = expectedPing === null ? 'null' : (expectedPing ? 'true' : 'false');
|
||||
document.getElementById('edit-ip-ping').value = pingValue;
|
||||
|
||||
// Set ports
|
||||
document.getElementById('edit-ip-tcp-ports').value = expectedTcpPorts && expectedTcpPorts.length > 0 ? expectedTcpPorts.join(',') : '';
|
||||
document.getElementById('edit-ip-udp-ports').value = expectedUdpPorts && expectedUdpPorts.length > 0 ? expectedUdpPorts.join(',') : '';
|
||||
|
||||
// Show modal
|
||||
showModal('editIpModal');
|
||||
}
|
||||
|
||||
// Save IP settings
|
||||
async function saveIp() {
|
||||
try {
|
||||
const siteId = document.getElementById('edit-ip-site-id').value;
|
||||
const ipId = document.getElementById('edit-ip-id').value;
|
||||
const ipAddress = document.getElementById('edit-ip-address').value;
|
||||
|
||||
const pingValue = document.getElementById('edit-ip-ping').value;
|
||||
const expectedPing = pingValue === 'null' ? null : (pingValue === 'true');
|
||||
|
||||
const expectedTcpPorts = parsePortList(document.getElementById('edit-ip-tcp-ports').value);
|
||||
const expectedUdpPorts = parsePortList(document.getElementById('edit-ip-udp-ports').value);
|
||||
|
||||
const response = await fetch(`/api/sites/${siteId}/ips/${ipId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_ping: expectedPing,
|
||||
expected_tcp_ports: expectedTcpPorts,
|
||||
expected_udp_ports: expectedUdpPorts
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('editIpModal')).hide();
|
||||
|
||||
// Reload the view site modal
|
||||
await viewSite(siteId);
|
||||
|
||||
showAlert('success', `IP ${ipAddress} updated successfully`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving IP:', error);
|
||||
showAlert('danger', `Error saving IP: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show alert
|
||||
function showAlert(type, message) {
|
||||
const alertHtml = `
|
||||
@@ -711,13 +1147,16 @@ document.getElementById('search-input').addEventListener('input', function(e) {
|
||||
// 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();
|
||||
// Setup save IP button
|
||||
document.getElementById('save-ip-btn').addEventListener('click', saveIp);
|
||||
|
||||
// Add cleanup listeners to all modals
|
||||
['createSiteModal', 'viewSiteModal', 'editSiteModal', 'deleteModal', 'editIpModal'].forEach(modalId => {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
modalElement.addEventListener('hidden.bs.modal', function() {
|
||||
// Clean up any stray backdrops when modal is fully hidden
|
||||
setTimeout(cleanupModalBackdrops, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// Load sites on page load
|
||||
|
||||
@@ -4,7 +4,7 @@ Pagination utilities for SneakyScanner web application.
|
||||
Provides helper functions for paginating SQLAlchemy queries.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ class PaginatedResult:
|
||||
|
||||
|
||||
def paginate(query: Query, page: int = 1, per_page: int = 20,
|
||||
transform: Optional[Callable[[Any], Dict[str, Any]]] = None,
|
||||
max_per_page: int = 100) -> PaginatedResult:
|
||||
"""
|
||||
Paginate a SQLAlchemy query.
|
||||
@@ -122,6 +123,7 @@ def paginate(query: Query, page: int = 1, per_page: int = 20,
|
||||
query: SQLAlchemy query to paginate
|
||||
page: Page number (1-indexed, default: 1)
|
||||
per_page: Items per page (default: 20)
|
||||
transform: Optional function to transform each item (default: None)
|
||||
max_per_page: Maximum items per page (default: 100)
|
||||
|
||||
Returns:
|
||||
@@ -133,6 +135,11 @@ def paginate(query: Query, page: int = 1, per_page: int = 20,
|
||||
>>> result = paginate(query, page=1, per_page=20)
|
||||
>>> scans = result.items
|
||||
>>> total_pages = result.pages
|
||||
|
||||
>>> # With transform function
|
||||
>>> def scan_to_dict(scan):
|
||||
... return {'id': scan.id, 'name': scan.name}
|
||||
>>> result = paginate(query, page=1, per_page=20, transform=scan_to_dict)
|
||||
"""
|
||||
# Validate and sanitize parameters
|
||||
page = max(1, page) # Page must be at least 1
|
||||
@@ -147,6 +154,10 @@ def paginate(query: Query, page: int = 1, per_page: int = 20,
|
||||
# Execute query with limit and offset
|
||||
items = query.limit(per_page).offset(offset).all()
|
||||
|
||||
# Apply transform if provided
|
||||
if transform is not None:
|
||||
items = [transform(item) for item in items]
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
total=total,
|
||||
|
||||
73
destroy_everything.sh
Executable file
73
destroy_everything.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
|
||||
# SneakyScan Fresh Start Script
|
||||
# This script removes all data, configs, and scan output for a clean slate
|
||||
|
||||
set -e
|
||||
|
||||
# Check for root/sudo access
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "============================================"
|
||||
echo " ERROR: Root access required"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "This script needs to run with sudo because"
|
||||
echo "Docker creates files with root ownership."
|
||||
echo ""
|
||||
echo "Please run:"
|
||||
echo " sudo ./destroy_everything.sh"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "============================================"
|
||||
echo " SneakyScan Fresh Start - DESTROY EVERYTHING"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "This will remove:"
|
||||
echo " - All database files in ./data/"
|
||||
echo " - All config files in ./configs/"
|
||||
echo " - All scan outputs in ./output/"
|
||||
echo ""
|
||||
read -p "Are you sure you want to continue? (yes/no): " -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Starting cleanup..."
|
||||
echo ""
|
||||
|
||||
# Clean data directory (database files)
|
||||
if [ -d "data" ]; then
|
||||
echo "Cleaning data directory..."
|
||||
rm -rfv data/*
|
||||
echo " Data directory cleaned"
|
||||
else
|
||||
echo " <20> Data directory not found"
|
||||
fi
|
||||
|
||||
# Clean configs directory
|
||||
if [ -d "configs" ]; then
|
||||
echo "Cleaning configs directory..."
|
||||
rm -rfv configs/*
|
||||
echo " Configs directory cleaned"
|
||||
else
|
||||
echo " <20> Configs directory not found"
|
||||
fi
|
||||
|
||||
# Clean output directory (scan results)
|
||||
if [ -d "output" ]; then
|
||||
echo "Cleaning output directory..."
|
||||
rm -rfv output/*
|
||||
echo " Output directory cleaned"
|
||||
else
|
||||
echo " <20> Output directory not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Fresh start complete! All data removed."
|
||||
echo "============================================"
|
||||
Reference in New Issue
Block a user