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:
2025-11-19 19:40:34 -06:00
parent 034f146fa1
commit 0ec338e252
21 changed files with 2004 additions and 686 deletions

View File

@@ -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})>"